Three single-file HTML apps ported to Makepad 2.0 (dev b41e740) script_mod! DSL: - rider (ride.html): 21 unit + 9 integration + 9 headless UI tests - koboyo (koboyo.html): 29 + 14 + 8 - insurance (insurance.html): 28 + 14 + 8 All 140 tests green. Includes headless-Linux platform patch, full README, setup script, and three brutal codebase assessments with execution plans.
1620 lines
114 KiB
HTML
1620 lines
114 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
|
||
<title>Koboyo · Infinite Canvas</title>
|
||
<style>
|
||
/* ============================================================
|
||
THEME TOKENS — extracted from koboyo.com root.css
|
||
============================================================ */
|
||
:root{
|
||
--chrome-surface:#ffffffe6;
|
||
--chrome-popup:#ffffff;
|
||
--chrome-ring:#0000000f;
|
||
--chrome-divider:#0000000d;
|
||
--chrome-hover:#f5f5f5;
|
||
--chrome-active:#d4d4d4;
|
||
--chrome-rest:#fafafa;
|
||
--chrome-fg-900:#171717;
|
||
--chrome-fg-800:#262626;
|
||
--chrome-fg-700:#404040;
|
||
--chrome-fg-600:#525252;
|
||
--chrome-fg-500:#737373;
|
||
--chrome-fg-400:#a3a3a3;
|
||
--chrome-accent:#171717;
|
||
--chrome-accent-hover:#404040;
|
||
--chrome-danger:#dc2626;
|
||
--canvas-bg:#ffffff;
|
||
--canvas-paper-tint:#2866c403;
|
||
--canvas-dot:#1f293721;
|
||
--canvas-ink:#1e1e1e;
|
||
--canvas-ink-muted:#495057;
|
||
--canvas-selection:#4da1ff;
|
||
--sticky:#ffe08a;
|
||
color-scheme:light;
|
||
}
|
||
html.dark{
|
||
--chrome-surface:#26262aeb;
|
||
--chrome-popup:#2a2a2e;
|
||
--chrome-ring:#ffffff17;
|
||
--chrome-divider:#ffffff14;
|
||
--chrome-hover:#ffffff14;
|
||
--chrome-active:#ffffff29;
|
||
--chrome-rest:#ffffff0d;
|
||
--chrome-fg-900:#f5f5f7;
|
||
--chrome-fg-800:#e9e9ee;
|
||
--chrome-fg-700:#d6d6dc;
|
||
--chrome-fg-600:#b6b6bf;
|
||
--chrome-fg-500:#9a9aa5;
|
||
--chrome-fg-400:#7d7d88;
|
||
--chrome-accent:#3f3f46;
|
||
--chrome-accent-hover:#52525b;
|
||
--chrome-danger:#f87171;
|
||
--canvas-bg:#1c1c1f;
|
||
--canvas-paper-tint:#ffffff04;
|
||
--canvas-dot:#e4e4e71c;
|
||
--canvas-ink:#e6e6e6;
|
||
--canvas-ink-muted:#a6a7ae;
|
||
--canvas-selection:#4dabf7;
|
||
--sticky:#6b5426;
|
||
color-scheme:dark;
|
||
}
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
html,body{height:100%}
|
||
body{
|
||
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display",Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||
background:var(--canvas-bg);
|
||
background-image:linear-gradient(var(--canvas-paper-tint),var(--canvas-paper-tint)),
|
||
radial-gradient(circle,var(--canvas-dot) .8px,transparent 1px);
|
||
background-size:auto,24px 24px;
|
||
color:var(--chrome-fg-900);
|
||
overflow:hidden;
|
||
-webkit-font-smoothing:antialiased;
|
||
transition:background-color .2s;
|
||
}
|
||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;-webkit-tap-highlight-color:transparent}
|
||
::selection{background:rgba(77,161,255,.3)}
|
||
|
||
/* ============================================================
|
||
SHARED CHROME PIECES
|
||
============================================================ */
|
||
.chip{
|
||
position:fixed;z-index:40;
|
||
background:var(--chrome-surface);
|
||
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
|
||
border-radius:12px;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 2px 8px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
|
||
padding:2px;
|
||
transition:opacity .22s,transform .22s;
|
||
}
|
||
.icon-btn{
|
||
position:relative;display:flex;align-items:center;justify-content:center;
|
||
width:32px;height:32px;border-radius:8px;color:var(--chrome-fg-600);
|
||
transition:background-color .15s,color .15s,scale .1s;
|
||
}
|
||
.icon-btn:hover{background:var(--chrome-hover);color:var(--chrome-fg-900)}
|
||
.icon-btn:active{scale:.95}
|
||
.icon-btn.on{background:var(--chrome-active);color:var(--chrome-fg-900)}
|
||
.icon-btn:disabled{opacity:.38;pointer-events:none}
|
||
.view-only .tool-btn:not(.always-on){opacity:.38;pointer-events:none}
|
||
.v-div{height:1px;margin:4px 6px;background:var(--chrome-divider)}
|
||
.h-div{width:1px;align-self:stretch;margin:6px 3px;background:var(--chrome-divider)}
|
||
|
||
/* popup panel shared */
|
||
.popup{
|
||
position:fixed;z-index:60;min-width:190px;padding:4px;
|
||
background:var(--chrome-popup);border-radius:12px;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 8px 28px rgba(0,0,0,.12),0 2px 8px rgba(0,0,0,.06);
|
||
animation:pin .13s ease;
|
||
max-height:calc(100dvh - 84px);overflow-y:auto;scrollbar-width:thin;
|
||
}
|
||
@keyframes pin{from{opacity:0;transform:translateY(-3px) scale(.98)}to{opacity:1;transform:none}}
|
||
.mi{
|
||
display:flex;align-items:center;gap:10px;width:100%;padding:7px 10px;border-radius:8px;
|
||
font-size:13px;font-weight:500;color:var(--chrome-fg-900);text-align:left;
|
||
transition:background-color .12s;
|
||
}
|
||
.mi:hover{background:var(--chrome-hover)}
|
||
.mi:disabled{opacity:.4;pointer-events:none}
|
||
.mi .mi-ic{flex:0 0 16px;display:grid;place-items:center;color:var(--chrome-fg-400)}
|
||
.mi .mi-sub{margin-left:auto;color:var(--chrome-fg-400);font-size:11.5px;display:flex;align-items:center;gap:6px}
|
||
.mi-sep{height:1px;margin:4px 6px;background:var(--chrome-divider)}
|
||
.mi-head{padding:7px 10px 5px;font-size:11px;font-weight:700;letter-spacing:.4px;color:var(--chrome-fg-400)}
|
||
|
||
/* switch */
|
||
.sw{position:relative;width:30px;height:17px;border-radius:999px;background:var(--chrome-fg-300);transition:background .18s;flex:0 0 30px}
|
||
.sw::after{content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:transform .18s;box-shadow:0 1px 2px rgba(0,0,0,.25)}
|
||
.sw.on{background:#4da1ff}
|
||
.sw.on::after{transform:translateX(13px)}
|
||
html.dark .sw{background:#5e5e66}
|
||
|
||
/* tooltip */
|
||
.tip{
|
||
position:fixed;z-index:100;pointer-events:none;display:flex;align-items:center;gap:7px;
|
||
background:var(--chrome-popup);color:var(--chrome-fg-900);font-size:11.5px;font-weight:600;
|
||
padding:5px 9px;border-radius:7px;box-shadow:0 0 0 1px var(--chrome-ring),0 3px 10px rgba(0,0,0,.14);
|
||
white-space:nowrap;opacity:0;transition:opacity .1s;
|
||
}
|
||
.tip kbd{
|
||
font:inherit;font-size:10px;color:var(--chrome-fg-500);background:var(--chrome-hover);
|
||
border-radius:4px;padding:1.5px 5px;box-shadow:inset 0 0 0 1px var(--chrome-ring);
|
||
}
|
||
|
||
/* ============================================================
|
||
TOP-LEFT: HAMBURGER + MAIN MENU
|
||
============================================================ */
|
||
.hamburger{left:16px;top:16px}
|
||
.hamburger .icon-btn{width:36px;height:28px}
|
||
.main-menu{top:56px;left:16px;min-width:200px}
|
||
.main-menu .user-head{padding:6px 10px 7px;line-height:1.35}
|
||
.main-menu .user-head b{display:block;font-size:13px;color:var(--chrome-fg-800);font-weight:600}
|
||
.main-menu .user-head span{font-size:12px;color:var(--chrome-fg-500)}
|
||
.submenu-anchor{position:relative}
|
||
.sub-fly{
|
||
position:absolute;top:-5px;left:min(calc(100% + 6px), calc(100vw - 200px));min-width:170px;padding:4px;
|
||
background:var(--chrome-popup);border-radius:12px;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 8px 28px rgba(0,0,0,.12);
|
||
animation:pin .12s ease;
|
||
}
|
||
|
||
/* ============================================================
|
||
DESKTOP TOOL RAIL (left, vertical)
|
||
============================================================ */
|
||
.tool-rail{
|
||
left:16px;top:56px;display:flex;flex-direction:column;gap:2px;padding:4px;
|
||
max-height:calc(100dvh - 72px);overflow-y:auto;overflow-x:visible;scrollbar-width:none;
|
||
}
|
||
.tool-rail::-webkit-scrollbar{display:none}
|
||
.tool-pop{min-width:0;padding:6px}
|
||
.tool-grid{display:grid;gap:4px}
|
||
.tool-grid.cols3{grid-template-columns:repeat(3,1fr)}
|
||
.tool-grid.cols4{grid-template-columns:repeat(4,1fr)}
|
||
.tool-grid.cols5{grid-template-columns:repeat(5,1fr)}
|
||
|
||
/* ============================================================
|
||
TOP-CENTER TITLE CHIP
|
||
============================================================ */
|
||
.title-chip{
|
||
left:50%;top:16px;transform:translateX(-50%);
|
||
display:flex;align-items:center;gap:2px;padding:3px;
|
||
}
|
||
.title-chip .login{
|
||
height:26px;padding:0 12px;border-radius:8px;white-space:nowrap;
|
||
background:var(--chrome-accent);color:#fff;font-size:12.5px;font-weight:650;
|
||
transition:background .15s;
|
||
}
|
||
html.dark .title-chip .login{color:var(--chrome-fg-900)}
|
||
.title-chip .login:hover{background:var(--chrome-accent-hover)}
|
||
.title-chip .doc-name{
|
||
max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||
padding:0 9px;font-size:13px;font-weight:550;color:var(--chrome-fg-900);outline:none;border-radius:6px;
|
||
}
|
||
.title-chip .doc-name:focus{background:var(--chrome-hover)}
|
||
|
||
/* ============================================================
|
||
TOP-RIGHT RAIL (desktop) + 3-DOTS (phone)
|
||
============================================================ */
|
||
.right-rail{right:16px;top:16px;display:flex;flex-direction:column;gap:2px;padding:4px}
|
||
.dots-btn{right:16px;top:16px;width:48px;height:44px;display:none;align-items:center;justify-content:center}
|
||
.dots-menu{top:68px;right:16px;min-width:196px}
|
||
|
||
/* ============================================================
|
||
BOTTOM-LEFT ZOOM + UNDO
|
||
============================================================ */
|
||
.bl-cluster{left:16px;bottom:16px;display:flex;gap:8px}
|
||
.zoom-chip,.hist-chip{display:flex;align-items:center;gap:2px;padding:4px}
|
||
.zoom-pct{
|
||
width:61px;height:32px;border-radius:8px;font-size:12.5px;font-weight:650;color:var(--chrome-fg-800);
|
||
display:flex;align-items:center;justify-content:center;transition:background .15s;font-variant-numeric:tabular-nums;
|
||
}
|
||
.zoom-pct:hover{background:var(--chrome-hover)}
|
||
|
||
/* ============================================================
|
||
BOTTOM-RIGHT HELP
|
||
============================================================ */
|
||
.help-fab{right:16px;bottom:16px;width:36px;height:36px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;color:var(--chrome-fg-500)}
|
||
.help-fab:hover{color:var(--chrome-fg-900)}
|
||
|
||
/* ============================================================
|
||
PHONE (<640px) — real site splits at 639px
|
||
============================================================ */
|
||
.phone-bar{
|
||
display:none;left:4px;right:4px;bottom:max(8px,env(safe-area-inset-bottom));
|
||
flex-direction:row;align-items:center;padding:2px;overflow-x:auto;scrollbar-width:none;
|
||
}
|
||
.phone-bar::-webkit-scrollbar{display:none}
|
||
.pgroup{display:flex;gap:2px;flex:0 0 auto}
|
||
@media(max-width:639px){
|
||
.desktop-only{display:none !important}
|
||
.phone-bar{display:flex}
|
||
.phone-bar .icon-btn{width:44px;height:44px;flex:0 0 44px}
|
||
.dots-btn{display:flex;width:48px;height:44px}
|
||
.hamburger{width:48px;height:44px;display:flex;align-items:center;justify-content:center;padding:0}
|
||
.hamburger .icon-btn{width:44px;height:40px}
|
||
.main-menu{top:68px;left:16px;max-width:calc(100vw - 32px)}
|
||
.dots-menu{left:auto;right:16px;max-width:calc(100vw - 32px)}
|
||
}
|
||
/* real site uses coarse:h-11 coarse:w-11 — 44px hit targets on every touch device */
|
||
@media(pointer:coarse){
|
||
.icon-btn{width:44px;height:44px}
|
||
.dots-btn .icon-btn{width:44px;height:40px}
|
||
}
|
||
|
||
/* ============================================================
|
||
CANVAS WORLD
|
||
============================================================ */
|
||
#stage{position:fixed;inset:0;overflow:hidden;touch-action:none}
|
||
#stage.tool-hand{cursor:grab}
|
||
#stage.panning{cursor:grabbing}
|
||
#world{position:absolute;left:0;top:0;transform-origin:0 0}
|
||
.el{position:absolute;user-select:none}
|
||
.el.sel::after{
|
||
content:'';position:absolute;inset:-6px;border:1.6px dashed var(--canvas-selection);border-radius:6px;pointer-events:none;
|
||
}
|
||
.el .et{width:100%;height:100%;overflow:hidden;outline:none}
|
||
.el-text .et{padding:2px 4px;font-size:28px;color:var(--canvas-ink);line-height:1.3;white-space:pre-wrap;word-break:break-word}
|
||
.el-sticky{background:var(--sticky);border-radius:4px;box-shadow:2px 4px 14px rgba(0,0,0,.14)}
|
||
.el-sticky .et{padding:10px 12px;font-size:15px;color:#3f2d00}
|
||
html.dark .el-sticky .et{color:#ffe9b8}
|
||
.el-badge{border-radius:999px;background:#fff2cc;border:2px solid #f5b93f;display:grid;place-items:center;font-weight:800;font-size:20px;color:#8a5a00}
|
||
html.dark .el-badge{background:#3d2f10;color:#ffd88a}
|
||
.el-img{border:2px dashed var(--chrome-fg-300);border-radius:10px;display:grid;place-items:center;background:var(--chrome-rest);font-size:34px}
|
||
.el-glyph{display:grid;place-items:center;font-size:44px}
|
||
.el-ent{background:var(--chrome-popup);border:2px solid var(--canvas-ink);border-radius:6px;font-size:13px;color:var(--canvas-ink)}
|
||
.el-ent .eh{border-bottom:2px solid var(--canvas-ink);padding:5px 9px;font-weight:800;text-align:center}
|
||
.el-ent .er{padding:4px 9px;font-size:12px;color:var(--canvas-ink-muted)}
|
||
.el-life .head{height:26%;background:var(--chrome-popup);border:2px solid var(--canvas-ink);border-radius:6px;display:grid;place-items:center;font-size:12px;font-weight:700;color:var(--canvas-ink)}
|
||
.el-life .line{width:0;height:72%;margin:0 auto;border-left:2px dashed var(--canvas-ink-muted)}
|
||
.el-frag{border:2px solid var(--canvas-ink);border-radius:6px;background:transparent}
|
||
.el-frag .tab{position:absolute;top:-2px;left:-2px;padding:4px 10px;background:var(--chrome-popup);border:2px solid var(--canvas-ink);border-radius:6px 0 4px 0;font-size:11px;font-weight:800;color:var(--canvas-ink)}
|
||
|
||
/* ============================================================
|
||
ONBOARDING HINTS (empty canvas)
|
||
============================================================ */
|
||
.coach{position:fixed;z-index:20;pointer-events:none;font-size:13.5px;font-weight:600;color:var(--chrome-fg-500);white-space:nowrap;transition:opacity .3s}
|
||
.coach svg{position:absolute;overflow:visible}
|
||
.coach svg path{fill:none;stroke:var(--chrome-fg-400);stroke-width:1.6;stroke-linecap:round}
|
||
.coach svg .ah{fill:var(--chrome-fg-400);stroke:none}
|
||
.center-hint{
|
||
position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:20;pointer-events:none;text-align:center;
|
||
transition:opacity .3s;
|
||
}
|
||
.center-hint .l1{font-size:17px;font-weight:650;color:var(--chrome-fg-600)}
|
||
.center-hint .l2{font-size:14px;color:var(--chrome-fg-400);margin-top:5px}
|
||
.hint-keys{display:flex;gap:14px;justify-content:center;margin-top:22px;flex-wrap:wrap}
|
||
.hint-keys span{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--chrome-fg-500)}
|
||
.hint-keys kbd{
|
||
font:inherit;font-size:11px;font-weight:750;color:var(--chrome-fg-700);
|
||
background:var(--chrome-popup);border-radius:6px;padding:3px 7px;min-width:24px;text-align:center;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 1px 2px rgba(0,0,0,.05);
|
||
}
|
||
.hidden-hints .coach,.hidden-hints .center-hint{opacity:0}
|
||
@media(max-width:639px){.coach,.hint-keys{display:none}}
|
||
|
||
/* ============================================================
|
||
RIGHT SIDE PANELS (Slides / Diagram as code / AI)
|
||
============================================================ */
|
||
.side-panel{
|
||
position:fixed;top:64px;right:16px;bottom:16px;width:min(300px,86vw);z-index:35;
|
||
background:var(--chrome-surface);backdrop-filter:blur(10px);border-radius:14px;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 12px 40px rgba(0,0,0,.12);
|
||
display:flex;flex-direction:column;overflow:hidden;
|
||
animation:slideIn .22s cubic-bezier(.22,1,.36,1);
|
||
}
|
||
@keyframes slideIn{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
|
||
.sp-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--chrome-divider);font-size:13.5px;font-weight:700}
|
||
.sp-head .x{margin-left:auto;width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--chrome-fg-500)}
|
||
.sp-head .x:hover{background:var(--chrome-hover);color:var(--chrome-fg-900)}
|
||
.sp-body{flex:1;overflow-y:auto;padding:14px;font-size:12.5px;color:var(--chrome-fg-600);line-height:1.6}
|
||
.sp-body .fake{height:9px;border-radius:5px;background:var(--chrome-rest);margin-bottom:9px}
|
||
.sp-btn{display:block;width:100%;margin-top:10px;padding:9px 0;border-radius:9px;background:var(--chrome-accent);color:#fff;font-size:13px;font-weight:650}
|
||
html.dark .sp-btn{color:var(--chrome-fg-900)}
|
||
.code-view{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:11.5px;background:var(--chrome-rest);border-radius:9px;padding:12px;white-space:pre;color:var(--chrome-fg-700)}
|
||
|
||
/* ============================================================
|
||
ALL-TOOLS DRAWER — anchored to the toolbar (right on desktop,
|
||
above the bottom bar on phone). Spec from koboyo tool-drawer.
|
||
============================================================ */
|
||
.drawer{
|
||
position:fixed;z-index:71;width:256px;
|
||
background:var(--chrome-popup);border-radius:12px;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 12px 38px rgba(0,0,0,.16),0 2px 8px rgba(0,0,0,.06);
|
||
display:flex;flex-direction:column;overflow:hidden;
|
||
animation:pin .13s ease;
|
||
}
|
||
.cmd-head{display:flex;align-items:center;gap:8px;padding:0 12px;border-bottom:1px solid var(--chrome-ring);color:var(--chrome-fg-400);flex:0 0 auto}
|
||
.cmd-head input{flex:1;background:none;border:0;outline:0;font:inherit;font-size:13.5px;color:var(--chrome-fg-900);padding:9px 0;min-width:0}
|
||
.cmd-head input::placeholder{color:var(--chrome-fg-400)}
|
||
.cmd-list{overflow-y:auto;padding:4px;min-height:0;scrollbar-width:thin}
|
||
.cmd-group{padding:7px 8px 2px;font-size:10.5px;font-weight:650;letter-spacing:.5px;text-transform:uppercase;color:var(--chrome-fg-400)}
|
||
.cmd-item{
|
||
display:flex;align-items:center;gap:8px;width:100%;padding:4px 8px;border-radius:8px;
|
||
text-align:left;font-size:13px;color:var(--chrome-fg-800);transition:background .1s;
|
||
}
|
||
.cmd-item:hover{background:var(--chrome-rest)}
|
||
.cmd-item.active{background:var(--chrome-hover)}
|
||
.cmd-item .ci-ic{width:20px;height:20px;display:grid;place-items:center;color:var(--chrome-fg-500);flex:0 0 20px}
|
||
.cmd-item.active .ci-ic{color:var(--chrome-fg-700)}
|
||
.cmd-item .ci-lbl{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.cmd-item .ci-hint{margin-left:auto;font-size:11px;color:var(--chrome-fg-500);background:var(--chrome-divider);border-radius:6px;padding:2px 6px;font-family:inherit}
|
||
.cmd-empty{padding:24px 12px;text-align:center;font-size:12.5px;color:var(--chrome-fg-400)}
|
||
|
||
/* ============================================================
|
||
SHORTCUTS DIALOG
|
||
============================================================ */
|
||
.sc-veil{position:fixed;inset:0;z-index:70;background:rgba(0,0,0,.24);animation:pin .14s}
|
||
.sc-dialog{
|
||
position:fixed;z-index:71;left:50%;top:50%;transform:translate(-50%,-50%);
|
||
width:min(720px,calc(100vw - 28px));max-height:min(600px,86dvh);overflow-y:auto;
|
||
background:var(--chrome-popup);border-radius:16px;padding:22px 24px;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 28px 80px rgba(0,0,0,.26);
|
||
animation:pin .15s;
|
||
}
|
||
.sc-dialog h2{font-size:16px;font-weight:750;margin-bottom:16px}
|
||
.sc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px}
|
||
.sc-group h3{font-size:11px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:var(--chrome-fg-400);margin-bottom:8px}
|
||
.sc-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:3.5px 0;font-size:12.5px;color:var(--chrome-fg-700)}
|
||
.sc-row kbd{
|
||
font:inherit;font-size:10.5px;font-weight:700;color:var(--chrome-fg-600);
|
||
background:var(--chrome-hover);border-radius:5px;padding:2px 6px;
|
||
box-shadow:inset 0 0 0 1px var(--chrome-ring);white-space:nowrap;
|
||
}
|
||
|
||
/* zen exit */
|
||
.zen-exit{
|
||
position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:45;
|
||
display:none;align-items:center;gap:8px;padding:9px 16px;border-radius:999px;font-size:13px;font-weight:650;
|
||
background:var(--chrome-surface);backdrop-filter:blur(10px);color:var(--chrome-fg-800);
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 8px 24px rgba(0,0,0,.14);
|
||
}
|
||
.zen .zen-exit{display:flex}
|
||
.zen .chrome-ui{opacity:0;pointer-events:none;transform:translateY(6px)}
|
||
.zen .tool-rail{transform:translateX(-10px)}
|
||
.zen .right-rail,.zen .dots-btn{transform:translateX(10px)}
|
||
.zen .title-chip{transform:translateX(-50%) translateY(-10px)}
|
||
|
||
/* toast */
|
||
.toast{
|
||
position:fixed;left:50%;bottom:64px;transform:translateX(-50%) translateY(10px);z-index:120;
|
||
background:var(--chrome-popup);color:var(--chrome-fg-900);font-size:13px;font-weight:600;
|
||
padding:10px 18px;border-radius:999px;opacity:0;pointer-events:none;
|
||
box-shadow:0 0 0 1px var(--chrome-ring),0 10px 30px rgba(0,0,0,.16);
|
||
transition:opacity .25s,transform .25s;white-space:nowrap;
|
||
}
|
||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||
@media(max-width:639px){.toast{bottom:84px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ==================== CANVAS ==================== -->
|
||
<div id="stage">
|
||
<div id="world"></div>
|
||
</div>
|
||
|
||
<!-- ==================== ONBOARDING ==================== -->
|
||
<div class="center-hint chrome-ui" id="centerHint">
|
||
<div class="l1">Double-click anywhere to add text</div>
|
||
<div class="l2">or pick a tool</div>
|
||
<div class="hint-keys">
|
||
<span><kbd>R</kbd> rectangle</span>
|
||
<span><kbd>O</kbd> ellipse</span>
|
||
<span><kbd>A</kbd> arrow</span>
|
||
<span><kbd>P</kbd> draw</span>
|
||
<span><kbd>/</kbd> all tools</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="coach chrome-ui" style="left:120px;top:120px" id="coach1">
|
||
Pick a tool from here
|
||
<svg width="70" height="46" style="left:-66px;top:4px"><path d="M62 12 C 34 6, 16 14, 8 38"/><path class="ah" d="M5 31 L 8 41 L 14 33 Z"/></svg>
|
||
</div>
|
||
<div class="coach chrome-ui" style="right:74px;top:60px" id="coach2">
|
||
Slides & Diagrams as Code
|
||
<svg width="56" height="34" style="right:-48px;top:-2px"><path d="M6 26 C 24 26, 40 20, 48 8"/><path class="ah" d="M44 6 L 52 4 L 49 12 Z"/></svg>
|
||
</div>
|
||
<div class="coach chrome-ui" style="left:130px;bottom:88px" id="coach3">
|
||
Zoom & reset view
|
||
<svg width="56" height="34" style="left:-52px;bottom:-4px"><path d="M50 6 C 26 8, 12 14, 6 26"/><path class="ah" d="M4 20 L 6 30 L 12 23 Z"/></svg>
|
||
</div>
|
||
<div class="coach chrome-ui" style="right:96px;bottom:88px" id="coach4">
|
||
Keyboard shortcuts
|
||
<svg width="50" height="32" style="right:-44px;bottom:-2px"><path d="M4 6 C 16 10, 28 14, 40 24"/><path class="ah" d="M36 17 L 44 26 L 34 26 Z"/></svg>
|
||
</div>
|
||
|
||
<!-- ==================== TOP-LEFT: HAMBURGER + MAIN MENU ==================== -->
|
||
<div class="chip hamburger chrome-ui" id="hamChip">
|
||
<button class="icon-btn" id="hamBtn" aria-label="Menu" data-tip="">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="popup main-menu" id="mainMenu" hidden>
|
||
<div class="user-head"><b>Guest session</b><span>Saved on this device</span></div>
|
||
<div class="mi-sep"></div>
|
||
<button class="mi" data-act="new"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></span>New canvas…<span class="mi-sub">⌘⇧N</span></button>
|
||
<button class="mi" data-act="canvases"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><rect x="14" y="4" width="7" height="7" rx="1.5"/><rect x="3" y="15" width="7" height="6" rx="1.5"/><rect x="14" y="15" width="7" height="6" rx="1.5"/></svg></span>My canvases</button>
|
||
<div class="mi-sep"></div>
|
||
<button class="mi" data-act="open"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V6a2 2 0 0 1 2-2h4l2 3h6a2 2 0 0 1 2 2v2"/><path d="M4 19h14l3-8H7l-3 8Z"/></svg></span>Open…</button>
|
||
<button class="mi" data-act="import"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M8.5 11.5 12 15l3.5-3.5M5 19.5h14"/></svg></span>Import…</button>
|
||
<button class="mi" data-act="save"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h11l3 3v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/><path d="M8 4v4h7V4M8 20v-6h8v6"/></svg></span>Save<span class="mi-sub">⌘S</span></button>
|
||
<div class="mi-sep"></div>
|
||
<div class="submenu-anchor">
|
||
<button class="mi" data-sub="export"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V4M8.5 7.5 12 4l3.5 3.5M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3"/></svg></span>Export<span class="mi-sub">›</span></button>
|
||
<div class="sub-fly" id="subExport" hidden>
|
||
<button class="mi" data-act="expng"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m5.5 17 4.5-4 3 3 2.5-2 3 3"/></svg></span>PNG image</button>
|
||
<button class="mi" data-act="exsvg"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z"/><path d="M14 3v5h5M9 13.5 6.5 16 9 18.5M15 13.5l2.5 2.5-2.5 2.5"/></svg></span>SVG image</button>
|
||
</div>
|
||
</div>
|
||
<div class="submenu-anchor">
|
||
<button class="mi" data-sub="view"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12s3.5-6 9-6 9 6 9 6-3.5 6-9 6-9-6-9-6Z"/><circle cx="12" cy="12" r="2.6"/></svg></span>View<span class="mi-sub">›</span></button>
|
||
<div class="sub-fly" id="subView" hidden>
|
||
<button class="mi" data-toggle="dark"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11Z"/></svg></span>Dark mode<span class="mi-sub"><span class="sw" id="swDark"></span></span></button>
|
||
<button class="mi" data-toggle="zen"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2.5"/><path d="M9 9h6M9 12.5h6"/></svg></span>Zen mode<span class="mi-sub"><span class="sw" id="swZen"></span></span></button>
|
||
<button class="mi" data-toggle="viewonly"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="10.5" width="14" height="9" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/></svg></span>View only<span class="mi-sub"><span class="sw" id="swView"></span></span></button>
|
||
</div>
|
||
</div>
|
||
<div class="mi-sep"></div>
|
||
<button class="mi" data-act="logout"><span class="mi-ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M9 12h12l-3-3M18 15l3-3"/></svg></span>Log out</button>
|
||
</div>
|
||
|
||
<!-- ==================== DESKTOP TOOL RAIL ==================== -->
|
||
<div class="chip tool-rail chrome-ui desktop-only" id="toolRail">
|
||
<button class="icon-btn tool-btn always-on" data-tool="select" data-tip="Select|V">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M5 3.5 18.5 12l-6.2 1.2L9 19.5 5 3.5Z"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn always-on" data-tool="hand" data-tip="Hand|H">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V11m0-6.5v-1a1.5 1.5 0 0 1 3 0V11m0-5.5a1.5 1.5 0 0 1 3 0v7m0-3a1.5 1.5 0 0 1 3 0v5c0 3.5-2.5 6.5-6.5 6.5-3 0-4.6-1.2-6.3-3.6L4.6 13c-.7-1-.2-2.2.8-2.5.7-.2 1.5 0 2 .6L8 12.6"/></svg>
|
||
</button>
|
||
<div class="v-div"></div>
|
||
<button class="icon-btn tool-btn" data-tool="rect" data-tip="Rectangle|R">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="4" y="6" width="16" height="12" rx="2"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" data-tool="ellipse" data-tip="Ellipse|O">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" id="moreShapesBtn" data-tip="More shapes" data-pop="popShapes">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="1.5"/><circle cx="16.8" cy="7.2" r="3.8"/><path d="M7.2 13.2 4 19.5h6.4l-3.2-6.3ZM15.5 13.5h4.6l2.3 6h-9.2l2.3-6Z"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" id="connectorBtn" data-tip="Connector|A" data-pop="popConnect">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 17 7m0 0h-6m6 0v6"/></svg>
|
||
</button>
|
||
<div class="v-div"></div>
|
||
<button class="icon-btn tool-btn" data-tool="text" data-tip="Text|T">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M5 6.5V5h14v1.5M12 5v14m-3 0h6"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" data-tool="sticky" data-tip="Sticky note|N">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M5 4h14v11l-5 5H5V4Z"/><path d="M19 15h-5v5"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" data-tool="draw" data-tip="Draw|P">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6 3.5 3.5L8.5 19H5v-3.5L14.5 6Z"/><path d="m13 7.5 3.5 3.5M5 19l2.5-2.5"/></svg>
|
||
</button>
|
||
<div class="v-div"></div>
|
||
<button class="icon-btn tool-btn" data-tool="image" data-tip="Image|I">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m5.5 17 4.5-4 3 3 2.5-2 3 3"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" data-tool="icons" data-tip="Icons|S">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3 2.1 4.9 5.3.5-4 3.6 1.2 5.2-4.6-2.7-4.6 2.7 1.2-5.2-4-3.6 5.3-.5L12 3Z"/></svg>
|
||
</button>
|
||
<button class="icon-btn tool-btn" data-tool="eraser" data-tip="Eraser|E">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m7 20-3.3-3.3a2 2 0 0 1 0-2.8L13 4.6a2 2 0 0 1 2.8 0l3.6 3.6a2 2 0 0 1 0 2.8L12 18.4M7 20h13M7 20l4-4"/></svg>
|
||
</button>
|
||
<div class="v-div"></div>
|
||
<button class="icon-btn tool-btn" id="lockBtn" data-tip="Keep tool active (lock)|⌘.">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><rect x="5.5" y="10.5" width="13" height="9" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/></svg>
|
||
</button>
|
||
<div class="v-div"></div>
|
||
<button class="icon-btn always-on" id="allToolsBtn" data-tip="All tools|/">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="7" height="7" rx="1.8"/><rect x="13" y="4" width="7" height="7" rx="1.8"/><rect x="4" y="13" width="7" height="7" rx="1.8"/><rect x="13" y="13" width="7" height="7" rx="1.8"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- tool flyouts -->
|
||
<div class="popup tool-pop" id="popShapes" hidden>
|
||
<div class="tool-grid cols4">
|
||
<button class="icon-btn" data-shape="diamond" data-tip2="Diamond"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M12 4 20 12l-8 8-8-8 8-8Z"/></svg></button>
|
||
<button class="icon-btn" data-shape="triangle" data-tip2="Triangle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M12 5 20.5 19h-17L12 5Z"/></svg></button>
|
||
<button class="icon-btn" data-shape="stepbadge" data-tip2="Step badge|B"><svg width="18" height="18" viewBox="0 0 24 24" fill="#f5b93f" stroke="#b57708" stroke-width="1.2"><circle cx="12" cy="12" r="8"/></svg></button>
|
||
<button class="icon-btn" data-shape="entity" data-tip2="Class / entity"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="5" y="4" width="14" height="16" rx="2"/><path d="M5 9.5h14M5 14.5h14" stroke-linecap="round"/></svg></button>
|
||
<button class="icon-btn" data-shape="lifeline" data-tip2="Lifeline"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><rect x="7" y="4" width="10" height="5" rx="1.5"/><path d="M12 11v9" stroke-dasharray="3 3"/></svg></button>
|
||
<button class="icon-btn" data-shape="fragment" data-tip2="Fragment"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1.5"/><path d="M4 6v5h7V4.5"/></svg></button>
|
||
</div>
|
||
</div>
|
||
<div class="popup tool-pop" id="popConnect" hidden>
|
||
<div class="tool-grid cols3">
|
||
<button class="icon-btn" data-conn="arrow" data-tip2="Arrow|A"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 17 7m0 0h-6m6 0v6"/></svg></button>
|
||
<button class="icon-btn" data-conn="line" data-tip2="Line|L"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"><path d="M5 19 19 5"/></svg></button>
|
||
<button class="icon-btn" data-conn="polyline" data-tip2="Multi-point line|C"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19 10 9l4 6 6-10"/></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== TOP-CENTER TITLE ==================== -->
|
||
<div class="chip title-chip chrome-ui desktop-only">
|
||
<button class="login" id="loginBtn">Log in to save</button>
|
||
<span class="doc-name" id="docName" contenteditable="true" spellcheck="false">Untitled canvas</span>
|
||
<button class="icon-btn" id="shareBtn" aria-label="Share">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 9H7a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1.5"/><path d="M12 15V3M8.5 6.5 12 3l3.5 3.5"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ==================== TOP-RIGHT RAIL (desktop) ==================== -->
|
||
<div class="chip right-rail chrome-ui desktop-only" id="rightRail">
|
||
<button class="icon-btn" id="aiBtn" data-tip="AI assistant" aria-label="AI assistant">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2ZM6.4 13.4l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="slidesBtn" data-tip="Slides" aria-label="Slides">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="12" rx="2"/><path d="M12 17v3M8.5 21.5 12 20l3.5 1.5"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="codeBtn" data-tip="Diagram as code" aria-label="Diagram as code">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.2 5.5l-2.4 13"/></svg>
|
||
</button>
|
||
<button class="icon-btn" id="commentsBtn" data-tip="Show comments" aria-label="Show comments">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M12 4c-4.6 0-8.3 3-8.3 6.8 0 2.1 1.2 4 3.1 5.3l-.8 3 3.3-1.6c.9.2 1.8.4 2.7.4 4.6 0 8.3-3 8.3-6.9S16.6 4 12 4Z"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ==================== TOP-RIGHT 3-DOTS (phone) ==================== -->
|
||
<div class="chip dots-btn chrome-ui" id="dotsChip">
|
||
<button class="icon-btn" id="dotsBtn" aria-label="More">
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="popup dots-menu" id="dotsMenu" hidden>
|
||
<button class="mi" data-act="undo" id="ovUndo"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></span>Undo</button>
|
||
<button class="mi" data-act="redo" id="ovRedo"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></span>Redo</button>
|
||
<div class="mi-sep"></div>
|
||
<button class="mi" data-act="fit"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5H5.5a2 2 0 0 0-2 2V8M16 3.5h2.5a2 2 0 0 1 2 2V8M8 20.5H5.5a2 2 0 0 1-2-2V16M16 20.5h2.5a2 2 0 0 0 2-2V16"/></svg></span>Zoom to fit</button>
|
||
<div class="mi-sep"></div>
|
||
<button class="mi" data-act="ai"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z"/></svg></span>AI assistant</button>
|
||
<button class="mi" data-act="slides"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="12" rx="2"/><path d="M12 17v3M8.5 21.5 12 20l3.5 1.5"/></svg></span>Slides</button>
|
||
<button class="mi" data-act="code"><span class="mi-ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.2 5.5l-2.4 13"/></svg></span>Diagram as code</button>
|
||
</div>
|
||
|
||
<!-- ==================== BOTTOM-LEFT ZOOM + HISTORY ==================== -->
|
||
<div class="bl-cluster chrome-ui desktop-only">
|
||
<div class="chip zoom-chip">
|
||
<button class="icon-btn" id="zoomOut" aria-label="Zoom out"><svg width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"><path d="M6 12h12"/></svg></button>
|
||
<button class="zoom-pct" id="zoomPct" aria-label="Reset zoom">100%</button>
|
||
<button class="icon-btn" id="zoomIn" aria-label="Zoom in"><svg width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"><path d="M12 6v12M6 12h12"/></svg></button>
|
||
<button class="icon-btn" id="zoomFit" aria-label="Zoom to fit"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3.5H5.5a2 2 0 0 0-2 2V8M16 3.5h2.5a2 2 0 0 1 2 2V8M8 20.5H5.5a2 2 0 0 1-2-2V16M16 20.5h2.5a2 2 0 0 0 2-2V16"/></svg></button>
|
||
</div>
|
||
<div class="chip hist-chip">
|
||
<button class="icon-btn" id="undoBtn" aria-label="Undo" disabled><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg></button>
|
||
<button class="icon-btn" id="redoBtn" aria-label="Redo" disabled><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== BOTTOM-RIGHT HELP ==================== -->
|
||
<div class="chip help-fab chrome-ui desktop-only" id="helpFab" aria-label="Keyboard shortcuts">?</div>
|
||
|
||
<!-- ==================== PHONE BOTTOM TOOLBAR ==================== -->
|
||
<div class="chip phone-bar chrome-ui" id="phoneBar">
|
||
<div class="pgroup">
|
||
<button class="icon-btn tool-btn always-on" data-tool="select" aria-label="Select"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M5 3.5 18.5 12l-6.2 1.2L9 19.5 5 3.5Z"/></svg></button>
|
||
<button class="icon-btn tool-btn always-on" data-tool="hand" aria-label="Hand"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V11m0-6.5v-1a1.5 1.5 0 0 1 3 0V11m0-5.5a1.5 1.5 0 0 1 3 0v7m0-3a1.5 1.5 0 0 1 3 0v5c0 3.5-2.5 6.5-6.5 6.5-3 0-4.6-1.2-6.3-3.6L4.6 13c-.7-1-.2-2.2.8-2.5.7-.2 1.5 0 2 .6L8 12.6"/></svg></button>
|
||
</div>
|
||
<div class="h-div"></div>
|
||
<div class="pgroup">
|
||
<button class="icon-btn tool-btn" data-tool="draw" aria-label="Draw"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 6 3.5 3.5L8.5 19H5v-3.5L14.5 6Z"/><path d="m13 7.5 3.5 3.5M5 19l2.5-2.5"/></svg></button>
|
||
<button class="icon-btn tool-btn" data-tool="eraser" aria-label="Eraser"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m7 20-3.3-3.3a2 2 0 0 1 0-2.8L13 4.6a2 2 0 0 1 2.8 0l3.6 3.6a2 2 0 0 1 0 2.8L12 18.4M7 20h13M7 20l4-4"/></svg></button>
|
||
</div>
|
||
<div class="h-div"></div>
|
||
<div class="pgroup">
|
||
<button class="icon-btn tool-btn" data-tool="rect" aria-label="Rectangle"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="4" y="6" width="16" height="12" rx="2"/></svg></button>
|
||
<button class="icon-btn tool-btn" id="moreShapesBtnM" aria-label="More shapes" data-pop="popShapesM"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7.5" height="7.5" rx="1.5"/><circle cx="16.8" cy="7.2" r="3.8"/><path d="M7.2 13.2 4 19.5h6.4l-3.2-6.3ZM15.5 13.5h4.6l2.3 6h-9.2l2.3-6Z"/></svg></button>
|
||
<button class="icon-btn tool-btn" id="connectorBtnM" aria-label="Connector" data-pop="popConnectM"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 17 7m0 0h-6m6 0v6"/></svg></button>
|
||
</div>
|
||
<div class="h-div"></div>
|
||
<div class="pgroup">
|
||
<button class="icon-btn always-on" id="allToolsBtnM" aria-label="All tools"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="7" height="7" rx="1.8"/><rect x="13" y="4" width="7" height="7" rx="1.8"/><rect x="4" y="13" width="7" height="7" rx="1.8"/><rect x="13" y="13" width="7" height="7" rx="1.8"/></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- phone flyouts (open upward) -->
|
||
<div class="popup tool-pop" id="popShapesM" hidden></div>
|
||
<div class="popup tool-pop" id="popConnectM" hidden></div>
|
||
|
||
<!-- ==================== SIDE PANELS ==================== -->
|
||
<div class="side-panel" id="panelSlides" hidden>
|
||
<div class="sp-head"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="12" rx="2"/><path d="M12 17v3M8.5 21.5 12 20l3.5 1.5"/></svg> Slides <button class="x" data-closepanel>✕</button></div>
|
||
<div class="sp-body">
|
||
<p>Turn your canvas into a slide deck. Draw a frame (<b>F</b>) and it becomes a slide.</p>
|
||
<div class="fake" style="width:82%"></div><div class="fake" style="width:64%"></div><div class="fake" style="width:74%"></div>
|
||
<button class="sp-btn" data-toast="Presentation started ⌘⌥P">▶ Present</button>
|
||
<button class="sp-btn" data-toast="Generating slides…" style="background:var(--chrome-rest);color:var(--chrome-fg-800)">✦ Generate slides with AI</button>
|
||
</div>
|
||
</div>
|
||
<div class="side-panel" id="panelCode" hidden>
|
||
<div class="sp-head"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m8.5 8-4 4 4 4M15.5 8l4 4-4 4M13.2 5.5l-2.4 13"/></svg> Diagram as code <button class="x" data-closepanel>✕</button></div>
|
||
<div class="sp-body">
|
||
<div class="code-view">graph TD
|
||
A[Start] --> B{Decision}
|
||
B -->|Yes| C[Ship it]
|
||
B -->|No| D[Rework]
|
||
D --> A</div>
|
||
<button class="sp-btn" data-toast="Code applied to canvas ✓">Apply to canvas</button>
|
||
</div>
|
||
</div>
|
||
<div class="side-panel" id="panelAI" hidden>
|
||
<div class="sp-head"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"><path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z"/></svg> AI assistant <button class="x" data-closepanel>✕</button></div>
|
||
<div class="sp-body">
|
||
<p>Ask AI to draw, explain or tidy up your canvas.</p>
|
||
<button class="sp-btn" data-toast='Prompt: "Draw a diagram of…"'>✦ Draw a diagram of…</button>
|
||
<button class="sp-btn" data-toast='Prompt: "Create a short deck about…"' style="background:var(--chrome-rest);color:var(--chrome-fg-800)">✦ Create a short deck about…</button>
|
||
</div>
|
||
</div>
|
||
<div class="side-panel" id="panelComments" hidden>
|
||
<div class="sp-head">Comments <button class="x" data-closepanel>✕</button></div>
|
||
<div class="sp-body"><p>No comments yet. Select something on the canvas and press <b>⇧C</b> to comment.</p></div>
|
||
</div>
|
||
|
||
<!-- ==================== ALL-TOOLS DRAWER ==================== -->
|
||
<div class="drawer" id="drawer" hidden>
|
||
<div class="cmd-head">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" style="flex:0 0 15px"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg>
|
||
<input id="drawerSearch" data-testid="tool-drawer-input" placeholder="Search all tools" aria-label="Search all tools">
|
||
</div>
|
||
<div class="cmd-list" id="drawerList"></div>
|
||
</div>
|
||
<svg id="laserFx" style="position:fixed;inset:0;z-index:34;pointer-events:none"></svg>
|
||
|
||
<!-- ==================== SHORTCUTS DIALOG ==================== -->
|
||
<div class="sc-veil" id="scVeil" hidden></div>
|
||
<div class="sc-dialog" id="scDialog" hidden>
|
||
<h2>Keyboard shortcuts</h2>
|
||
<div class="sc-grid" id="scGrid"></div>
|
||
</div>
|
||
|
||
<button class="zen-exit" id="zenExit">Exit zen mode <kbd style="font:inherit;font-size:11px;opacity:.6">⌥Z</kbd></button>
|
||
<div class="toast" id="toast">Saved</div>
|
||
<div class="tip" id="tooltip"></div>
|
||
|
||
<script>
|
||
/* ================================================================
|
||
STATE
|
||
================================================================ */
|
||
const S = {
|
||
tool:'select', shape:'rect', conn:'arrow',
|
||
lock:false, zen:false, viewOnly:false, dark:false,
|
||
zoom:1, pan:{x:80,y:80},
|
||
els:[], selId:null, past:[], future:[],
|
||
};
|
||
let uidc = 1;
|
||
const $ = id => document.getElementById(id);
|
||
const stage = $('stage'), world = $('world');
|
||
|
||
/* ================================================================
|
||
WORLD RENDER
|
||
================================================================ */
|
||
function applyView(){
|
||
world.style.transform = `translate(${S.pan.x}px,${S.pan.y}px) scale(${S.zoom})`;
|
||
$('zoomPct').textContent = Math.round(S.zoom*100)+'%';
|
||
}
|
||
function history(){
|
||
S.past.push(JSON.stringify(S.els)); if(S.past.length>60) S.past.shift();
|
||
S.future = [];
|
||
render();
|
||
}
|
||
function render(){
|
||
world.innerHTML = '';
|
||
S.els.forEach(el => world.appendChild(renderEl(el)));
|
||
$('undoBtn').disabled = !S.past.length;
|
||
$('redoBtn').disabled = !S.future.length;
|
||
$('ovUndo').disabled = !S.past.length;
|
||
$('ovRedo').disabled = !S.future.length;
|
||
document.body.classList.toggle('hidden-hints', S.els.length>0);
|
||
}
|
||
function renderEl(el){
|
||
const d = document.createElement('div');
|
||
d.className = 'el' + (S.selId===el.id ? ' sel':'') + extraCls(el);
|
||
d.dataset.id = el.id;
|
||
d.style.cssText = `left:${el.x}px;top:${el.y}px;width:${el.w}px;height:${el.h}px;`;
|
||
d.innerHTML = elHTML(el);
|
||
d.addEventListener('pointerdown', ev => onElPointer(el, ev));
|
||
return d;
|
||
}
|
||
const INK = getComputedStyle(document.documentElement).getPropertyValue('--canvas-ink');
|
||
function ink(){ return getComputedStyle(document.documentElement).getPropertyValue('--canvas-ink').trim(); }
|
||
function extraCls(el){
|
||
const m = {text:' el-text',sticky:' el-sticky',stepbadge:' el-badge',image:' el-img',icons:' el-glyph',
|
||
entity:' el-ent',lifeline:' el-life',fragment:' el-frag',gshape:' el-gshape',highlight:' el-hl',
|
||
chart:' el-box',table:' el-box',frame:' el-box',ctrl:' el-ctrl'}[el.kind];
|
||
return m||'';
|
||
}
|
||
function svgWrap(inner){ return `<svg width="100%" height="100%" style="display:block;overflow:visible">${inner}</svg>`; }
|
||
function elHTML(el){
|
||
const c = ink();
|
||
switch(el.kind){
|
||
case 'rect': return svgWrap(`<rect x="2" y="2" width="${Math.max(4,el.w-4)}" height="${Math.max(4,el.h-4)}" rx="7" fill="none" stroke="${c}" stroke-width="2.4"/>`);
|
||
case 'ellipse': return svgWrap(`<ellipse cx="${el.w/2}" cy="${el.h/2}" rx="${Math.max(4,el.w/2-2)}" ry="${Math.max(4,el.h/2-2)}" fill="none" stroke="${c}" stroke-width="2.4"/>`);
|
||
case 'diamond': return svgWrap(`<path d="M${el.w/2} 2 L${el.w-2} ${el.h/2} L${el.w/2} ${el.h-2} L2 ${el.h/2} Z" fill="none" stroke="${c}" stroke-width="2.4" stroke-linejoin="round"/>`);
|
||
case 'triangle': return svgWrap(`<path d="M${el.w/2} 2 L${el.w-2} ${el.h-2} L2 ${el.h-2} Z" fill="none" stroke="${c}" stroke-width="2.4" stroke-linejoin="round"/>`);
|
||
case 'text': return `<div class="et" contenteditable="${S.tool==='select'}" spellcheck="false">${esc(el.text)||'Text'}</div>`;
|
||
case 'sticky': return `<div class="et" contenteditable="${S.tool==='select'}" spellcheck="false">${esc(el.text)||'Note…'}</div>`;
|
||
case 'stepbadge': return `<b style="font-size:${Math.min(el.w,el.h)*.44}px">${el.n||1}</b>`;
|
||
case 'image': return el.emoji || '🖼️';
|
||
case 'icons': return el.emoji || '⭐';
|
||
case 'entity': return `<div class="eh">${esc(el.text)||'Entity'}</div><div class="er">+ id: UUID</div><div class="er">+ created: date</div>`;
|
||
case 'lifeline': return `<div class="head">${esc(el.text)||'Actor'}</div><div class="line"></div>`;
|
||
case 'fragment': return `<span class="tab">${esc(el.text)||'loop'}</span>`;
|
||
case 'gshape': return `<svg width="100%" height="100%" viewBox="0 0 24 24" preserveAspectRatio="none" style="display:block;overflow:visible;color:${ink()}"><g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${SHAPES24[el.g]||SHAPES24.rectangle}</g></svg>`;
|
||
case 'highlight':{
|
||
const pts = el.pts.map(p=>`${p.x-el.minx},${p.y-el.miny}`).join(' ');
|
||
return svgWrap(`<polyline points="${pts}" fill="none" stroke="rgba(255,213,74,.55)" stroke-width="15" stroke-linecap="round" stroke-linejoin="round"/>`);
|
||
}
|
||
case 'chart': return chartHTML(el.chart);
|
||
case 'table': return tableHTML();
|
||
case 'frame': return frameHTML(el.frame);
|
||
case 'ctrl': return ctrlHTML(el.ctrl);
|
||
case 'arrow': case 'line': case 'polyline': case 'draw':{
|
||
const pts = el.pts, w=el.w, h=el.h;
|
||
const pstr = pts.map(p=>`${p.x-el.minx},${p.y-el.miny}`).join(' ');
|
||
const head = (el.kind==='arrow');
|
||
return svgWrap(`${head?`<defs><marker id="ah${el.id}" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></marker></defs>`:''}
|
||
<polyline points="${pstr}" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" ${head?`marker-end="url(#ah${el.id})"`:''}/>`);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
function esc(s){ return (s||'').replace(/[&<>"]/g, m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }
|
||
world.addEventListener('input', e => {
|
||
const n = e.target.closest('.el'); if(!n) return;
|
||
const el = S.els.find(x => x.id == n.dataset.id); if(!el) return;
|
||
el.text = e.target.classList.contains('et') ? e.target.innerText
|
||
: e.target.classList.contains('eh') ? e.target.innerText
|
||
: e.target.innerText;
|
||
});
|
||
world.addEventListener('focusout', e => { if(e.target.closest('.el')) history(); });
|
||
|
||
/* ================================================================
|
||
SHAPE GLYPH LIBRARY (24×24, currentColor) — covers every shape
|
||
kind in koboyo's registry (Z map in their tools bundle)
|
||
================================================================ */
|
||
const SHAPES24 = {
|
||
'rectangle':'<rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/>',
|
||
'ellipse':'<ellipse cx="12" cy="12" rx="9.5" ry="7.5"/>',
|
||
'oval':'<ellipse cx="12" cy="12" rx="9.5" ry="7.5"/>',
|
||
'diamond':'<path d="M12 2.5 21.5 12 12 21.5 2.5 12Z"/>',
|
||
'triangle':'<path d="M12 3 21 20 3 20Z"/>',
|
||
'parallelogram':'<path d="M5.5 4h16l-3.5 16H2Z"/>',
|
||
'trapezoid':'<path d="M7 4h10l4.5 16h-19Z"/>',
|
||
'cylinder':'<ellipse cx="12" cy="5.5" rx="8" ry="3"/><path d="M4 5.5v13a8 3 0 0 0 16 0v-13"/>',
|
||
'note':'<path d="M4 3h13l3 3.2V21H4Z"/><path d="M17 3v3.5h3"/>',
|
||
'hexagon':'<path d="M7.5 3h9L21.5 12 16.5 21h-9L2.5 12Z"/>',
|
||
'star':'<path d="M12 2.8l2.8 5.9 6.4.7-4.8 4.3 1.4 6.3L12 16.9l-5.8 3.1 1.4-6.3-4.8-4.3 6.4-.7Z"/>',
|
||
'checkbox':'<rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="M7.5 12.2l3.2 3.2 5.8-6.4"/>',
|
||
'crossbox':'<rect x="3.5" y="3.5" width="17" height="17" rx="3"/><path d="M7.5 7.5l9 9M16.5 7.5l-9 9"/>',
|
||
'heart':'<path d="M12 20.3C5.7 15.7 3 12.3 3 8.9c0-2.7 2-4.4 4.3-4.4 1.8 0 3.5 1 4.7 2.6 1.2-1.6 2.9-2.6 4.7-2.6 2.3 0 4.3 1.7 4.3 4.4 0 3.4-2.7 6.8-9 11.4Z"/>',
|
||
'cloud':'<path d="M7 18.5a4.7 4.7 0 0 1-.6-9.4A6.2 6.2 0 0 1 18 10.7 4.1 4.1 0 0 1 17.6 18.5Z"/>',
|
||
'arrow-up':'<path d="M12 20.5v-17M5.3 10 12 3.3 18.7 10"/>',
|
||
'arrow-down':'<path d="M12 3.5v17M5.3 14 12 20.7 18.7 14"/>',
|
||
'arrow-left':'<path d="M20.5 12h-17M10 5.3 3.3 12 10 18.7"/>',
|
||
'arrow-right':'<path d="M3.5 12h17M14 5.3 20.7 12 14 18.7"/>',
|
||
'plus':'<path d="M12 3.5v17M3.5 12h17"/>',
|
||
'speech-bubble':'<path d="M4 4h16a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2h-7.5L8 20.5v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"/>',
|
||
'bolt':'<path d="M13.2 2.2 4.8 13.2H11L10 21.8l8.4-11H12Z"/>',
|
||
'queue':'<rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M7.6 5v14"/>',
|
||
'callout':'<ellipse cx="12" cy="9.5" rx="8" ry="5.5"/><path d="M8.5 14.2 5.5 20l6.5-5.2"/>',
|
||
'document':'<path d="M6 2.8h7.5L19 8v13.2H6Z"/><path d="M13.5 2.8V8H19"/>',
|
||
'predefined-process':'<rect x="3.5" y="5" width="17" height="14" rx="1.5"/><path d="M7.8 5v14M16.2 5v14"/>',
|
||
'manual-input':'<path d="M3.5 8l17-3.5V19.5H3.5Z"/><path d="M8 8.7v10.8"/>',
|
||
'pentagon':'<path d="M12 2.6l9.3 6.8-3.6 11H6.3l-3.6-11Z"/>',
|
||
'chevron':'<path d="M3.5 4h8.5l7.5 8-7.5 8H3.5l7.5-8Z"/>',
|
||
'octagon':'<path d="M8 3h8l5 5v8l-5 5H8l-5-5V8Z"/>',
|
||
'cube':'<path d="M12 2.6 20.6 7v10L12 21.4 3.4 17V7Z"/><path d="M3.4 7 12 11.6 20.6 7M12 11.6v9.8"/>',
|
||
'actor':'<circle cx="12" cy="5" r="2.6"/><path d="M12 7.8V14M6.6 9.8h10.8M12 14l-5 7M12 14l5 7"/>',
|
||
'double-circle':'<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4.6"/>',
|
||
'card':'<rect x="3.5" y="5" width="17" height="14" rx="2"/><path d="M7 9.2h10M7 12.6h7"/>',
|
||
'delay':'<path d="M5 4h6.5a8 8 0 0 1 0 16H5Z"/>',
|
||
'fork-join':'<rect x="3" y="10.4" width="18" height="3.2" rx="1" fill="currentColor" stroke="none"/>',
|
||
'multi-document':'<path d="M9 5.2l4-2.7 5.5 5.5"/><path d="M6 8.5h7L18 13v8.5H6Z"/><path d="M13 8.5V13h5"/>',
|
||
'multi-process':'<rect x="6.5" y="8.5" width="13" height="11" rx="1.5"/><path d="M9.5 8.5v-3h11v11h-3"/>',
|
||
'package':'<path d="M4 6.5h16v13H4Z"/><path d="M4 6.5 7 3.8h10l3 2.7M9.5 3.8v3h5v-3"/>',
|
||
'component':'<rect x="6" y="4" width="12" height="16" rx="1.5"/><path d="M3.5 6.5H6M3.5 10H6M18 6.5h2.5M18 10h2.5"/>',
|
||
'brace':'<path d="M9.5 3.2C7 3.2 6.8 4.3 6.8 6v2.8c0 1.6-.9 2.6-2.6 3.2 1.7.6 2.6 1.6 2.6 3.2V18c0 1.7.2 2.8 2.7 2.8M14.5 3.2c2.5 0 2.7 1.1 2.7 2.8v2.8c0 1.6.9 2.6 2.6 3.2-1.7.6-2.6 1.6-2.6 3.2V18c0 1.7-.2 2.8-2.7 2.8"/>',
|
||
};
|
||
const SHAPE_KINDS = [
|
||
['diamond','Diamond'],['triangle','Triangle'],['oval','Oval'],['parallelogram','Parallelogram'],
|
||
['trapezoid','Trapezoid'],['cylinder','Cylinder'],['note','Note'],['hexagon','Hexagon'],['star','Star'],
|
||
['checkbox','Checkbox'],['crossbox','Crossed box'],['heart','Heart'],['cloud','Cloud'],
|
||
['arrow-up','Arrow up'],['arrow-down','Arrow down'],['arrow-left','Arrow left'],['arrow-right','Arrow right'],
|
||
['plus','Plus'],['speech-bubble','Speech bubble'],['bolt','Lightning bolt'],['queue','Queue'],
|
||
['callout','Callout'],['document','Document'],['predefined-process','Predefined process'],
|
||
['manual-input','Manual input'],['pentagon','Pentagon'],['chevron','Chevron'],['octagon','Octagon'],
|
||
['cube','Cube'],['actor','Actor'],['double-circle','Double circle'],['card','Card'],['delay','Delay'],
|
||
['fork-join','Fork / join'],['multi-document','Multi-document'],['multi-process','Multi-process'],
|
||
['package','Package'],['component','Component'],['brace','Brace'],
|
||
];
|
||
|
||
/* ================================================================
|
||
INSERT RENDERERS — charts, table, frames, UI controls
|
||
================================================================ */
|
||
const A_ = s => s; // passthrough helper for readability
|
||
function chartHTML(kind){
|
||
const P='#4da1ff',G='#34c77b',O='#ff9f3d',R='#f2698a',M='var(--canvas-ink-muted)',I='var(--canvas-ink)';
|
||
const box=(inner,vb="0 0 190 100")=>`<div style="width:100%;height:100%;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:8px;overflow:hidden;display:flex;flex-direction:column">
|
||
<div style="padding:7px 10px 4px;font-size:11px;font-weight:700;color:${I};letter-spacing:.2px">Chart</div>
|
||
<svg viewBox="${vb}" style="flex:1;width:100%;min-height:0" preserveAspectRatio="xMidYMid meet">${inner}</svg></div>`;
|
||
switch(kind){
|
||
case 'bar': return box(`${[26,54,86,118,150].map((x,i)=>`<rect x="${x}" y="${[42,26,60,18,50][i]}" width="22" height="${[48,64,30,72,40][i]}" rx="3" fill="${[P,G,O,R,P][i]}"/>`).join('')}<line x1="14" y1="90" x2="180" y2="90" stroke="${M}" stroke-width="1.4"/>`);
|
||
case 'line': return box(`<polyline points="18,74 46,52 74,62 102,34 130,44 158,22 180,30" fill="none" stroke="${P}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><line x1="14" y1="90" x2="180" y2="90" stroke="${M}" stroke-width="1.4"/>`);
|
||
case 'area': return box(`<path d="M18 74 46 52 74 62 102 34 130 44 158 22 180 30 V90 H18 Z" fill="${P}" opacity=".22"/><polyline points="18,74 46,52 74,62 102,34 130,44 158,22 180,30" fill="none" stroke="${P}" stroke-width="2.4"/>`);
|
||
case 'scatter': return box(`${[[30,70],[52,44],[66,60],[88,30],[104,52],[124,60],[142,26],[164,46],[176,34],[60,74],[112,74]].map(p=>`<circle cx="${p[0]}" cy="${p[1]}" r="4" fill="${P}"/>`).join('')}<line x1="14" y1="90" x2="180" y2="90" stroke="${M}" stroke-width="1.4"/>`);
|
||
case 'pie': return box(`<circle cx="95" cy="52" r="34" fill="${G}"/><path d="M95 52 95 18 A34 34 0 0 1 127 64 Z" fill="${P}"/><path d="M95 52 127 64 A34 34 0 0 1 78 81 Z" fill="${O}"/>`);
|
||
case 'donut': return box(`<circle cx="95" cy="52" r="34" fill="none" stroke="${G}" stroke-width="16"/><path d="M95 18 A34 34 0 0 1 127 64" fill="none" stroke="${P}" stroke-width="16"/><path d="M127 64 A34 34 0 0 1 78 81" fill="none" stroke="${O}" stroke-width="16"/>`);
|
||
case 'radar': return box(`<polygon points="95,14 128,38 116,78 74,78 62,38" fill="none" stroke="${M}" stroke-width="1.2"/><polygon points="95,26 117,42 110,68 80,68 73,42" fill="none" stroke="${M}" stroke-width="1"/><polygon points="95,22 120,44 108,72 82,64 78,40" fill="${P}" opacity=".3" stroke="${P}" stroke-width="1.8"/>`);
|
||
case 'quadrant': return box(`<line x1="95" y1="14" x2="95" y2="90" stroke="${M}" stroke-width="1.4"/><line x1="24" y1="52" x2="168" y2="52" stroke="${M}" stroke-width="1.4"/>${[[60,34],[120,30],[76,66],[138,60],[52,50]].map((p,i)=>`<circle cx="${p[0]}" cy="${p[1]}" r="5" fill="${[P,G,O,R,P][i]}"/>`).join('')}`);
|
||
}
|
||
return chartHTML('bar');
|
||
}
|
||
function tableHTML(){
|
||
const I='var(--canvas-ink)',M='var(--canvas-ink-muted)';
|
||
return `<div style="width:100%;height:100%;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:8px;overflow:hidden">
|
||
${[0,1,2,3].map(r=>`<div style="display:flex;height:25%;border-top:${r?'1.2px solid '+M:'none'}">${[0,1,2].map(c=>`<div style="flex:1;border-left:${c?'1.2px solid '+M:'none'};background:${r===0?'var(--chrome-rest)':'transparent'}"></div>`).join('')}</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
function frameHTML(kind){
|
||
const I='var(--canvas-ink)',M='var(--canvas-ink-muted)';
|
||
if(kind==='browser') return `<div style="width:100%;height:100%;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:10px;overflow:hidden">
|
||
<div style="height:26px;display:flex;align-items:center;gap:6px;padding:0 10px;border-bottom:1.4px solid ${M}"><span style="width:8px;height:8px;border-radius:50%;background:#f2698a"></span><span style="width:8px;height:8px;border-radius:50%;background:#ffc53d"></span><span style="width:8px;height:8px;border-radius:50%;background:#34c77b"></span><span style="flex:1;height:14px;margin-left:6px;border:1.2px solid ${M};border-radius:7px"></span></div>
|
||
<div style="padding:10px"><div style="height:8px;border-radius:4px;background:var(--chrome-rest);width:70%;margin-bottom:8px"></div><div style="height:8px;border-radius:4px;background:var(--chrome-rest);width:52%"></div></div></div>`;
|
||
if(kind==='phone') return `<div style="width:100%;height:100%;background:var(--chrome-popup);border:2px solid ${I};border-radius:16px;overflow:hidden;position:relative">
|
||
<div style="width:44px;height:7px;border-radius:4px;background:${M};margin:8px auto"></div>
|
||
<div style="margin:10px 12px;height:8px;border-radius:4px;background:var(--chrome-rest);width:64%"></div>
|
||
<div style="margin:10px 12px;height:8px;border-radius:4px;background:var(--chrome-rest);width:44%"></div></div>`;
|
||
return `<div style="position:relative;width:100%;height:100%;border:1.6px solid ${I};border-radius:6px">
|
||
<span style="position:absolute;top:-1.6px;left:-1.6px;background:var(--chrome-popup);border:1.6px solid ${I};border-radius:6px 0 4px 0;padding:3px 10px;font-size:11px;font-weight:800;color:${I}">frame</span></div>`;
|
||
}
|
||
const CTRL_DEF = {
|
||
button:[120,36], input:[200,36], search:[200,36], checkbox:[110,26], radio:[110,26], toggle:[48,26],
|
||
dropdown:[200,36], slider:[180,24], navbar:[300,42], tabs:[240,34], image:[160,110], avatar:[42,42],
|
||
heading:[160,30], paragraph:[220,56], divider:[200,10], link:[64,22], textarea:[220,88],
|
||
stepper:[120,36], badge:[76,24], progress:[200,16], breadcrumbs:[180,22], pagination:[172,28],
|
||
rating:[112,24], card:[220,150], list:[220,110], alert:[240,44], modal:[240,150], menu:[160,140],
|
||
tooltip:[150,30], accordion:[240,120], datepicker:[200,36], sidebar:[260,150], scrollbar:[200,12],
|
||
segmented:[150,30], tabbar:[260,46], statusbar:[300,22], fab:[48,48],
|
||
};
|
||
function ctrlHTML(kind){
|
||
const I='var(--canvas-ink)',M='var(--canvas-ink-muted)',B='#4da1ff',POP='var(--chrome-popup)',RS='var(--chrome-rest)';
|
||
const d='display:block;width:100%;height:100%';
|
||
switch(kind){
|
||
case 'button': return `<svg style="${d}" viewBox="0 0 120 36" preserveAspectRatio="none"><rect x="1" y="1" width="118" height="34" rx="8" fill="${B}"/><text x="60" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="#fff">Button</text></svg>`;
|
||
case 'input': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="14" y="22" font-size="12.5" fill="${M}">Input</text><line x1="10" y1="10" x2="10" y2="26" stroke="${B}" stroke-width="1.6"/></svg>`;
|
||
case 'search': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="17" fill="${POP}" stroke="${I}" stroke-width="1.6"/><circle cx="20" cy="17" r="6" fill="none" stroke="${M}" stroke-width="1.6"/><line x1="24.5" y1="21.5" x2="28" y2="25" stroke="${M}" stroke-width="1.6"/><text x="38" y="22" font-size="12.5" fill="${M}">Search…</text></svg>`;
|
||
case 'checkbox': return `<svg style="${d}" viewBox="0 0 110 26" preserveAspectRatio="none"><rect x="2" y="4" width="18" height="18" rx="4.5" fill="${B}"/><path d="M6.5 13.2l2.6 2.6 4.6-5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/><text x="28" y="17.5" font-size="12.5" fill="currentColor">Checkbox</text></svg>`;
|
||
case 'radio': return `<svg style="${d}" viewBox="0 0 110 26" preserveAspectRatio="none"><circle cx="11" cy="13" r="9" fill="${POP}" stroke="${B}" stroke-width="1.8"/><circle cx="11" cy="13" r="4.5" fill="${B}"/><text x="28" y="17.5" font-size="12.5" fill="currentColor">Radio</text></svg>`;
|
||
case 'toggle': return `<svg style="${d}" viewBox="0 0 48 26" preserveAspectRatio="none"><rect x="1" y="2" width="46" height="22" rx="11" fill="${B}"/><circle cx="36" cy="13" r="8" fill="#fff"/></svg>`;
|
||
case 'dropdown': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="14" y="22" font-size="12.5" fill="currentColor">Dropdown</text><path d="M176 14.5l6 6 6-6" stroke="${M}" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||
case 'slider': return `<svg style="${d}" viewBox="0 0 180 24" preserveAspectRatio="none"><line x1="4" y1="12" x2="176" y2="12" stroke="${M}" stroke-width="3.5" stroke-linecap="round"/><line x1="4" y1="12" x2="104" y2="12" stroke="${B}" stroke-width="3.5" stroke-linecap="round"/><circle cx="104" cy="12" r="7.5" fill="#fff" stroke="${B}" stroke-width="2"/></svg>`;
|
||
case 'navbar': return `<svg style="${d}" viewBox="0 0 300 42" preserveAspectRatio="none"><rect x="1" y="1" width="298" height="40" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><circle cx="22" cy="21" r="7" fill="${B}"/><text x="40" y="25" font-size="12" font-weight="700" fill="currentColor">Brand</text>${[210,240,270].map(x=>`<rect x="${x-14}" y="16" width="24" height="9" rx="4.5" fill="${RS}"/>`).join('')}</svg>`;
|
||
case 'tabs': return `<svg style="${d}" viewBox="0 0 240 34" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="32" rx="9" fill="${RS}" stroke="${M}"/><rect x="4" y="4" width="76" height="26" rx="7" fill="${POP}" stroke="${I}" stroke-width="1.2"/><text x="42" y="21" text-anchor="middle" font-size="11.5" fill="currentColor">Tab 1</text><text x="120" y="21" text-anchor="middle" font-size="11.5" fill="${M}">Tab 2</text><text x="198" y="21" text-anchor="middle" font-size="11.5" fill="${M}">Tab 3</text></svg>`;
|
||
case 'image': return `<svg style="${d}" viewBox="0 0 160 110" preserveAspectRatio="none"><rect x="1" y="1" width="158" height="108" rx="10" fill="${RS}" stroke="${M}" stroke-dasharray="5 4" stroke-width="1.6"/><circle cx="52" cy="42" r="10" fill="none" stroke="${M}" stroke-width="1.8"/><path d="M40 84 78 48l22 22 16-14 26 28" fill="none" stroke="${M}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||
case 'avatar': return `<svg style="${d}" viewBox="0 0 42 42" preserveAspectRatio="none"><circle cx="21" cy="21" r="19" fill="${RS}" stroke="${I}" stroke-width="1.6"/><circle cx="21" cy="16" r="6.4" fill="none" stroke="${M}" stroke-width="1.8"/><path d="M9 35c2.5-7 7-9.5 12-9.5s9.5 2.5 12 9.5" fill="none" stroke="${M}" stroke-width="1.8"/></svg>`;
|
||
case 'heading': return `<svg style="${d}" viewBox="0 0 160 30" preserveAspectRatio="none"><text x="2" y="23" font-size="24" font-weight="800" fill="currentColor">Heading</text></svg>`;
|
||
case 'paragraph': return `<svg style="${d}" viewBox="0 0 220 56" preserveAspectRatio="none">${[0,16,32,48].map((y,i)=>`<rect x="0" y="${y}" width="${i===3 ? 120 : 218}" height="8" rx="4" fill="${RS}" stroke="${M}" stroke-width="1"/>`).join('')}</svg>`;
|
||
case 'divider': return `<svg style="${d}" viewBox="0 0 200 10" preserveAspectRatio="none"><line x1="0" y1="5" x2="200" y2="5" stroke="${M}" stroke-width="1.6"/></svg>`;
|
||
case 'link': return `<svg style="${d}" viewBox="0 0 64 22" preserveAspectRatio="none"><text x="2" y="16" font-size="14" fill="${B}">Link</text><line x1="2" y1="19" x2="34" y2="19" stroke="${B}" stroke-width="1.4"/></svg>`;
|
||
case 'textarea': return `<svg style="${d}" viewBox="0 0 220 88" preserveAspectRatio="none"><rect x="1" y="1" width="218" height="86" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="12" y="22" font-size="12" fill="${M}">Text area…</text></svg>`;
|
||
case 'stepper': return `<svg style="${d}" viewBox="0 0 120 36" preserveAspectRatio="none"><rect x="1" y="1" width="118" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="20" y="22.5" text-anchor="middle" font-size="14" fill="${M}">−</text><text x="60" y="22" text-anchor="middle" font-size="13" fill="currentColor">1</text><text x="100" y="23" text-anchor="middle" font-size="14" fill="${M}">+</text><line x1="38" y1="7" x2="38" y2="29" stroke="${M}"/><line x1="82" y1="7" x2="82" y2="29" stroke="${M}"/></svg>`;
|
||
case 'badge': return `<svg style="${d}" viewBox="0 0 76 24" preserveAspectRatio="none"><rect x="1" y="1" width="74" height="22" rx="11" fill="${B}"/><text x="38" y="16" text-anchor="middle" font-size="11.5" font-weight="700" fill="#fff">Badge</text></svg>`;
|
||
case 'progress': return `<svg style="${d}" viewBox="0 0 200 16" preserveAspectRatio="none"><rect x="0" y="4" width="200" height="8" rx="4" fill="${RS}" stroke="${M}"/><rect x="0" y="4" width="124" height="8" rx="4" fill="${B}"/></svg>`;
|
||
case 'breadcrumbs': return `<svg style="${d}" viewBox="0 0 180 22" preserveAspectRatio="none"><text x="2" y="15" font-size="12.5" fill="${B}">Home</text><text x="46" y="15" font-size="12.5" fill="${M}">/</text><text x="58" y="15" font-size="12.5" fill="${B}">Library</text><text x="108" y="15" font-size="12.5" fill="${M}">/</text><text x="120" y="15" font-size="12.5" fill="currentColor">Data</text></svg>`;
|
||
case 'pagination': return `<svg style="${d}" viewBox="0 0 172 28" preserveAspectRatio="none">${['‹','1','2','3','…','›'].map((t,i)=>{const on=i===1;return `<rect x="${2+i*28}" y="2" width="24" height="24" rx="6.5" fill="${on?B:POP}" stroke="${on?'none':M}" stroke-width="1.2"/><text x="${14+i*28}" y="18.5" text-anchor="middle" font-size="12" fill="${on?'#fff':'currentColor'}">${t}</text>`}).join('')}</svg>`;
|
||
case 'rating': return `<svg style="${d}" viewBox="0 0 112 24" preserveAspectRatio="none">${[0,1,2,3,4].map(i=>`<path d="M${12+i*22} 3l2.5 5.2 5.7.6-4.3 3.8 1.2 5.6-5.1-2.9-5.1 2.9 1.2-5.6-4.3-3.8 5.7-.6Z" fill="${i<4?'#ffc53d':RS}" stroke="${i<4?'#e8a70c':M}" stroke-width="1"/>`).join('')}</svg>`;
|
||
case 'card': return `<svg style="${d}" viewBox="0 0 220 150" preserveAspectRatio="none"><rect x="1" y="1" width="218" height="148" rx="11" fill="${POP}" stroke="${I}" stroke-width="1.6"/><rect x="13" y="14" width="194" height="66" rx="7" fill="${RS}"/><text x="16" y="102" font-size="14" font-weight="700" fill="currentColor">Card title</text><rect x="14" y="112" width="150" height="7" rx="3.5" fill="${RS}"/><rect x="14" y="128" width="70" height="14" rx="7" fill="${B}"/></svg>`;
|
||
case 'list': return `<svg style="${d}" viewBox="0 0 220 110" preserveAspectRatio="none"><rect x="1" y="1" width="218" height="108" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[0,1,2].map(i=>`<g><circle cx="22" cy="${25+i*31}" r="7" fill="${RS}" stroke="${M}"/><rect x="38" y="${19+i*31}" width="130" height="6" rx="3" fill="${RS}"/><rect x="38" y="${28+i*31}" width="86" height="5" rx="2.5" fill="${RS}"/>${i?`<line x1="10" y1="${9+i*31}" x2="210" y2="${9+i*31}" stroke="${M}" stroke-width="1"/>`:''}</g>`).join('')}</svg>`;
|
||
case 'alert': return `<svg style="${d}" viewBox="0 0 240 44" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="42" rx="9" fill="rgba(77,161,255,.12)" stroke="${B}" stroke-width="1.5"/><circle cx="22" cy="22" r="8" fill="${B}"/><text x="22" y="26" text-anchor="middle" font-size="11" font-weight="800" fill="#fff">i</text><text x="40" y="26" font-size="12.5" fill="currentColor">Something to note</text></svg>`;
|
||
case 'modal': return `<svg style="${d}" viewBox="0 0 240 150" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="148" rx="11" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="16" y="30" font-size="14" font-weight="800" fill="currentColor">Modal dialog</text><rect x="16" y="44" width="208" height="7" rx="3.5" fill="${RS}"/><rect x="16" y="58" width="150" height="7" rx="3.5" fill="${RS}"/><rect x="130" y="112" width="46" height="22" rx="7" fill="${RS}"/><rect x="182" y="112" width="46" height="22" rx="7" fill="${B}"/><text x="205" y="127" text-anchor="middle" font-size="10.5" font-weight="700" fill="#fff">OK</text></svg>`;
|
||
case 'menu': return `<svg style="${d}" viewBox="0 0 160 140" preserveAspectRatio="none"><rect x="1" y="1" width="158" height="138" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[0,1,2,3].map(i=>`${i?`<line x1="8" y1="${8+i*32}" x2="152" y2="${8+i*32}" stroke="${M}" stroke-width="1"/>`:''}<rect x="16" y="${17+i*32}" width="10" height="10" rx="3" fill="${RS}" stroke="${M}"/><rect x="34" y="${19+i*32}" width="80" height="6" rx="3" fill="${RS}"/>`).join('')}</svg>`;
|
||
case 'tooltip': return `<svg style="${d}" viewBox="0 0 150 30" preserveAspectRatio="none"><path d="M1 8a7 7 0 0 1 7-7h134a7 7 0 0 1 7 7v10a7 7 0 0 1-7 7H81l-6 5-6-5H8a7 7 0 0 1-7-7Z" fill="currentColor"/><text x="75" y="17" text-anchor="middle" font-size="11.5" fill="${POP}">Tooltip text</text></svg>`;
|
||
case 'accordion': return `<svg style="${d}" viewBox="0 0 240 120" preserveAspectRatio="none"><rect x="1" y="1" width="238" height="118" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[12,52,86].map((y,i)=>`<rect x="14" y="${y+8}" width="90" height="7" rx="3.5" fill="${RS}"/><path d="M${214-i*0} ${y+9}l5 5 5-5" stroke="${M}" stroke-width="1.6" fill="none" stroke-linecap="round"/>${i?`<line x1="8" y1="${y}" x2="232" y2="${y}" stroke="${M}" stroke-width="1"/>`:''}`).join('')}<rect x="14" y="32" width="180" height="6" rx="3" fill="${RS}"/></svg>`;
|
||
case 'datepicker': return `<svg style="${d}" viewBox="0 0 200 36" preserveAspectRatio="none"><rect x="1" y="1" width="198" height="34" rx="8" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="14" y="22" font-size="12.5" fill="${M}">24 Feb 2001</text><rect x="172" y="9" width="18" height="18" rx="4.5" fill="none" stroke="${M}" stroke-width="1.4"/><line x1="176" y1="6.5" x2="176" y2="11" stroke="${M}" stroke-width="1.4"/><line x1="186" y1="6.5" x2="186" y2="11" stroke="${M}" stroke-width="1.4"/><line x1="172" y1="14" x2="190" y2="14" stroke="${M}" stroke-width="1.4"/></svg>`;
|
||
case 'sidebar': return `<svg style="${d}" viewBox="0 0 260 150" preserveAspectRatio="none"><rect x="1" y="1" width="258" height="148" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><path d="M1 10a9 9 0 0 1 9-9h64v148H10a9 9 0 0 1-9-9Z" fill="${RS}"/><rect x="14" y="18" width="44" height="7" rx="3.5" fill="${M}"/>${[0,1,2].map(i=>`<rect x="14" y="${40+i*18}" width="52" height="6" rx="3" fill="${POP}"/>`).join('')}<rect x="90" y="18" width="80" height="8" rx="4" fill="${RS}"/><rect x="90" y="34" width="130" height="6" rx="3" fill="${RS}"/></svg>`;
|
||
case 'scrollbar': return `<svg style="${d}" viewBox="0 0 200 12" preserveAspectRatio="none"><rect x="0" y="0" width="200" height="12" rx="6" fill="${RS}" stroke="${M}"/><rect x="30" y="0" width="70" height="12" rx="6" fill="${M}"/></svg>`;
|
||
case 'segmented': return `<svg style="${d}" viewBox="0 0 150 30" preserveAspectRatio="none"><rect x="1" y="1" width="148" height="28" rx="9" fill="${RS}" stroke="${M}"/><rect x="4" y="4" width="70" height="22" rx="7" fill="${POP}" stroke="${I}" stroke-width="1.1"/><text x="39" y="19.5" text-anchor="middle" font-size="11.5" fill="currentColor">One</text><text x="112" y="19.5" text-anchor="middle" font-size="11.5" fill="${M}">Two</text></svg>`;
|
||
case 'tabbar': return `<svg style="${d}" viewBox="0 0 260 46" preserveAspectRatio="none"><rect x="1" y="1" width="258" height="44" rx="12" fill="${POP}" stroke="${I}" stroke-width="1.6"/>${[0,1,2,3].map(i=>`<g><rect x="${34+i*54}" y="12" width="14" height="12" rx="4" fill="${i?RS:B}" stroke="${i?M:'none'}" stroke-width="1.2"/><rect x="${32+i*54}" y="30" width="18" height="5" rx="2.5" fill="${i?RS:M}"/></g>`).join('')}</svg>`;
|
||
case 'statusbar': return `<svg style="${d}" viewBox="0 0 300 22" preserveAspectRatio="none"><rect x="1" y="1" width="298" height="20" rx="9" fill="${POP}" stroke="${I}" stroke-width="1.6"/><text x="16" y="15" font-size="10.5" font-weight="700" fill="currentColor">9:41</text><rect x="248" y="7" width="16" height="8" rx="2" fill="none" stroke="${M}"/><rect x="250" y="9" width="9" height="4" rx="1" fill="currentColor"/><path d="M266 9.5v3" stroke="${M}"/>${[0,1,2,3].map(i=>`<rect x="${226+i*4}" y="${13-i*2}" width="2.6" height="${3+i*2}" rx="1" fill="currentColor"/>`).join('')}</svg>`;
|
||
case 'fab': return `<svg style="${d}" viewBox="0 0 48 48" preserveAspectRatio="none"><circle cx="24" cy="24" r="22" fill="${B}"/><path d="M24 15v18M15 24h18" stroke="#fff" stroke-width="3" stroke-linecap="round"/></svg>`;
|
||
}
|
||
return ctrlHTML('button');
|
||
}
|
||
|
||
/* ================================================================
|
||
INSERT AT VIEW CENTER (registry "insertControl" equivalent)
|
||
================================================================ */
|
||
function insertAtCenter(kind, extra={}){
|
||
const r = stage.getBoundingClientRect();
|
||
const cx = (r.width/2 - S.pan.x)/S.zoom, cy = (r.height/2 - S.pan.y)/S.zoom;
|
||
let spec = {w:200,h:140};
|
||
if(kind==='ctrl'){ const [w,h] = CTRL_DEF[extra.ctrl]||[180,40]; spec = {w,h}; }
|
||
if(kind==='chart') spec = {w:220,h:150};
|
||
if(kind==='table') spec = {w:240,h:140};
|
||
if(kind==='frame') spec = extra.frame==='phone' ? {w:170,h:320} : extra.frame==='browser' ? {w:340,h:230} : {w:320,h:220};
|
||
const el = addEl({kind, x:cx-spec.w/2, y:cy-spec.h/2, ...spec, ...extra});
|
||
S.selId = el.id; render();
|
||
toast((extra.ctrl||extra.chart||extra.frame||kind)+' added');
|
||
}
|
||
|
||
/* ================================================================
|
||
TOOLS
|
||
================================================================ */
|
||
const DRAG_TOOLS = ['rect','ellipse','diamond','triangle','fragment'];
|
||
const CONN_TOOLS = ['arrow','line','polyline'];
|
||
const PLACE_TOOLS= ['text','sticky','image','icons','stepbadge','entity','lifeline'];
|
||
function toolKey(){ return S.tool==='connector' ? S.conn : S.tool; }
|
||
|
||
function setTool(t, opts={}){
|
||
if(opts.conn) S.conn = opts.conn;
|
||
if(opts.g) S.gshape = opts.g;
|
||
S.tool = t;
|
||
const MORE = ['diamond','triangle','stepbadge','entity','lifeline','fragment','gshape'];
|
||
document.querySelectorAll('.tool-btn').forEach(b => {
|
||
const k = b.dataset.tool; // undefined on popup buttons
|
||
let on = false;
|
||
if(S.tool==='connector' || CONN_TOOLS.includes(S.tool)) on = (b.id==='connectorBtn' || b.id==='connectorBtnM');
|
||
else if(MORE.includes(S.tool)) on = (b.id==='moreShapesBtn' || b.id==='moreShapesBtnM');
|
||
else on = (k && k===S.tool);
|
||
b.classList.toggle('on', !!on);
|
||
});
|
||
stage.classList.toggle('tool-hand', S.tool==='hand');
|
||
S.selId = null; render();
|
||
}
|
||
document.querySelectorAll('.tool-btn[data-tool]').forEach(b => {
|
||
b.addEventListener('click', () => {
|
||
if(b.dataset.pop) return; // popup buttons handled separately
|
||
S.shape = 'rect';
|
||
setTool(b.dataset.tool);
|
||
});
|
||
});
|
||
$('connectorBtn').addEventListener('click', e => { setTool('connector'); togglePop('popConnect', e.currentTarget); });
|
||
$('connectorBtnM').addEventListener('click', e => { setTool('connector'); togglePop('popConnectM', e.currentTarget, true); });
|
||
$('moreShapesBtn').addEventListener('click', e => togglePop('popShapes', e.currentTarget));
|
||
$('moreShapesBtnM').addEventListener('click', e => togglePop('popShapesM', e.currentTarget, true));
|
||
|
||
document.querySelectorAll('[data-shape]').forEach(b => b.addEventListener('click', () => {
|
||
const k = b.dataset.shape;
|
||
closePops();
|
||
if(k==='stepbadge'||k==='entity'||k==='lifeline') setTool(k);
|
||
else setTool(k==='fragment'?'fragment':k);
|
||
}));
|
||
document.querySelectorAll('[data-conn]').forEach(b => b.addEventListener('click', () => {
|
||
closePops(); setTool('connector',{conn:b.dataset.conn});
|
||
}));
|
||
$('lockBtn').addEventListener('click', () => {
|
||
S.lock=!S.lock; $('lockBtn').classList.toggle('on',S.lock);
|
||
toast(S.lock?'Tool locked — keeps active':'Tool unlocked');
|
||
});
|
||
|
||
/* phone flyouts: shapes grid is cols-5 and PREPENDS Ellipse on the phone
|
||
(desktop rail already has a dedicated Ellipse button) */
|
||
const shapeWire = p => p.querySelectorAll('[data-shape]').forEach(b => b.addEventListener('click', () => {
|
||
closePops();
|
||
const k = b.dataset.shape;
|
||
if(k==='stepbadge'||k==='entity'||k==='lifeline') setTool(k);
|
||
else setTool(k);
|
||
}));
|
||
$('popShapesM').innerHTML =
|
||
`<div class="tool-grid cols5">
|
||
<button class="icon-btn" data-shape="ellipse"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg></button>
|
||
${$('popShapes').querySelector('.tool-grid').innerHTML}
|
||
</div>`;
|
||
$('popConnectM').innerHTML = $('popConnect').innerHTML;
|
||
shapeWire($('popShapesM'));
|
||
$('popConnectM').querySelectorAll('[data-conn]').forEach(b => b.addEventListener('click', () => { closePops(); setTool('connector',{conn:b.dataset.conn}); }));
|
||
|
||
/* ================================================================
|
||
CANVAS INTERACTION
|
||
================================================================ */
|
||
let drag = null;
|
||
function toWorld(e){
|
||
const r = stage.getBoundingClientRect();
|
||
return { x:(e.clientX-r.left - S.pan.x)/S.zoom, y:(e.clientY-r.top - S.pan.y)/S.zoom };
|
||
}
|
||
stage.addEventListener('pointerdown', e => {
|
||
closeAllMenus();
|
||
if(e.button===2 || S.tool==='hand'){ // pan
|
||
drag = {mode:'pan', sx:e.clientX, sy:e.clientY, px:S.pan.x, py:S.pan.y};
|
||
stage.classList.add('panning'); stage.setPointerCapture(e.pointerId); return;
|
||
}
|
||
if(S.viewOnly) return;
|
||
const p = toWorld(e);
|
||
if(S.tool==='select'){ S.selId=null; render(); drag={mode:'maybe'}; return; }
|
||
if(S.tool==='gshape'){
|
||
drag = {mode:'create', kind:'gshape', g:S.gshape, x0:p.x, y0:p.y, el:null, pointerId:e.pointerId};
|
||
stage.setPointerCapture(e.pointerId);
|
||
} else if(S.tool==='highlight'){
|
||
drag = {mode:'draw', kind:'highlight', points:[p], pointerId:e.pointerId};
|
||
stage.setPointerCapture(e.pointerId);
|
||
} else if(S.tool==='laser'){
|
||
laserStart(e);
|
||
} else if(S.tool==='lasso'){
|
||
lassoStart(e,p);
|
||
} else if(DRAG_TOOLS.includes(S.tool)){
|
||
drag = {mode:'create', kind:S.tool, x0:p.x, y0:p.y, el:null, pointerId:e.pointerId};
|
||
stage.setPointerCapture(e.pointerId);
|
||
} else if(CONN_TOOLS.includes(S.tool==='connector'?S.conn:S.tool) || S.tool==='draw'){
|
||
const kind = S.tool==='draw'?'draw':S.conn;
|
||
drag = {mode:'draw', kind, points:[p], pointerId:e.pointerId};
|
||
stage.setPointerCapture(e.pointerId);
|
||
} else if(PLACE_TOOLS.includes(S.tool)){
|
||
const el = placeAt(S.tool, p);
|
||
if(!S.lock) setTool('select');
|
||
if(S.tool==='select' && el) focusEl(el);
|
||
}
|
||
});
|
||
stage.addEventListener('pointermove', e => {
|
||
if(!drag) return;
|
||
if(drag.mode==='pan'){
|
||
S.pan.x = drag.px + (e.clientX-drag.sx); S.pan.y = drag.py + (e.clientY-drag.sy);
|
||
applyView(); return;
|
||
}
|
||
const p = toWorld(e);
|
||
if(drag.mode==='laser'){
|
||
drag.pts.push(e.clientX+','+e.clientY);
|
||
drag.poly.setAttribute('points', drag.pts.join(' '));
|
||
return;
|
||
}
|
||
if(drag.mode==='lasso'){
|
||
drag.pts.push([e.clientX,e.clientY]);
|
||
drag.poly.setAttribute('points', drag.pts.map(q=>q.join(',')).join(' '));
|
||
return;
|
||
}
|
||
if(drag.mode==='create'){
|
||
const x = Math.min(drag.x0,p.x), y = Math.min(drag.y0,p.y);
|
||
const w = Math.max(6,Math.abs(p.x-drag.x0)), h = Math.max(6,Math.abs(p.y-drag.y0));
|
||
if(!drag.el){ drag.el = addEl({kind:drag.kind,x,y,w,h,g:drag.g},false); }
|
||
else Object.assign(drag.el,{x,y,w,h});
|
||
S.selId = drag.el.id; render();
|
||
} else if(drag.mode==='draw'){
|
||
drag.points.push(p);
|
||
if(drag.points.length>2 && !drag.el){
|
||
drag.el = addEl({kind:drag.kind,pts:drag.points,...bbox(drag.points)},false);
|
||
} else if(drag.el){ Object.assign(drag.el,{pts:drag.points,...bbox(drag.points)}); }
|
||
S.selId = drag.el?.id ?? null; render();
|
||
}
|
||
});
|
||
function endDrag(e){
|
||
if(!drag) return;
|
||
if(drag.mode==='laser'){
|
||
const poly = drag.poly;
|
||
poly.style.transition = 'opacity .8s ease';
|
||
poly.style.opacity = '0';
|
||
setTimeout(()=>poly.remove(), 850);
|
||
if(!S.lock) setTool('select');
|
||
drag=null; return;
|
||
}
|
||
if(drag.mode==='lasso'){
|
||
const sr = stage.getBoundingClientRect();
|
||
const inPoly = (px,py,pts)=>{ let c=false;
|
||
for(let i=0,j=pts.length-1;i<pts.length;j=i++){
|
||
const xi=pts[i][0],yi=pts[i][1],xj=pts[j][0],yj=pts[j][1];
|
||
if(((yi>py)!==(yj>py)) && (px < (xj-xi)*(py-yi)/(yj-yi)+xi)) c=!c;
|
||
} return c; };
|
||
let hit=null;
|
||
for(const el of S.els){
|
||
const cx = sr.left + S.pan.x + (el.x+el.w/2)*S.zoom;
|
||
const cy = sr.top + S.pan.y + (el.y+el.h/2)*S.zoom;
|
||
if(drag.pts.length>2 && inPoly(cx,cy,drag.pts)) hit=el;
|
||
}
|
||
drag.poly.remove();
|
||
S.selId = hit ? hit.id : null;
|
||
if(!S.lock) setTool('select');
|
||
render(); drag=null; return;
|
||
}
|
||
if((drag.mode==='create'||drag.mode==='draw') && drag.el){ history(); }
|
||
if(drag.mode==='create'||drag.mode==='draw'){
|
||
if(!S.lock) setTool('select');
|
||
S.selId = drag.el?.id ?? S.selId; render();
|
||
}
|
||
drag=null; stage.classList.remove('panning');
|
||
}
|
||
stage.addEventListener('pointerup', endDrag);
|
||
stage.addEventListener('pointercancel', endDrag);
|
||
stage.addEventListener('contextmenu', e => e.preventDefault());
|
||
stage.addEventListener('dblclick', e => {
|
||
if(S.viewOnly) return;
|
||
if(e.target.closest('.el')) return;
|
||
const p = toWorld(e);
|
||
const el = placeAt('text', p);
|
||
setTool('select');
|
||
if(el) focusEl(el);
|
||
});
|
||
stage.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const d = e.deltaY>0 ? -0.08 : 0.08;
|
||
setZoom(S.zoom + d, {x:e.clientX, y:e.clientY});
|
||
}, {passive:false});
|
||
|
||
/* laser pointer (present-mode red trail, fades, no history) */
|
||
function laserStart(e){
|
||
const fx = $('laserFx');
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg','polyline');
|
||
poly.setAttribute('fill','none');
|
||
poly.setAttribute('stroke','#f43f5e');
|
||
poly.setAttribute('stroke-width','4');
|
||
poly.setAttribute('stroke-linecap','round');
|
||
poly.setAttribute('stroke-linejoin','round');
|
||
poly.setAttribute('opacity','.9');
|
||
fx.appendChild(poly);
|
||
drag = {mode:'laser', poly, pts:[e.clientX+','+e.clientY], pointerId:e.pointerId};
|
||
stage.setPointerCapture(e.pointerId);
|
||
}
|
||
/* lasso select — free polygon, selects topmost element whose centre falls inside */
|
||
function lassoStart(e,p){
|
||
const fx = $('laserFx');
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg','polygon');
|
||
poly.setAttribute('fill','rgba(77,161,255,.12)');
|
||
poly.setAttribute('stroke','var(--canvas-selection)');
|
||
poly.setAttribute('stroke-width','1.6');
|
||
poly.setAttribute('stroke-dasharray','5 4');
|
||
fx.appendChild(poly);
|
||
drag = {mode:'lasso', poly, pts:[[e.clientX,e.clientY]], pointerId:e.pointerId};
|
||
stage.setPointerCapture(e.pointerId);
|
||
}
|
||
|
||
function bbox(pts){
|
||
const xs = pts.map(p=>p.x), ys = pts.map(p=>p.y);
|
||
const minx = Math.min(...xs)-4, miny = Math.min(...ys)-4;
|
||
return {minx, miny, x:minx, y:miny, w:Math.max(...xs)-minx+8, h:Math.max(...ys)-miny+8};
|
||
}
|
||
function addEl(el, commit=true){
|
||
el.id = uidc++;
|
||
S.els.push(el);
|
||
if(commit) history(); else render();
|
||
return el;
|
||
}
|
||
function placeAt(kind, p, focus=false){
|
||
const P = {
|
||
text:{x:p.x-40,y:p.y-16,w:120,h:36,text:''},
|
||
sticky:{x:p.x-70,y:p.y-60,w:150,h:130,text:''},
|
||
image:{x:p.x-60,y:p.y-45,w:120,h:90,emoji:'🌄'},
|
||
icons:{x:p.x-30,y:p.y-30,w:60,h:60,emoji:'⭐'},
|
||
stepbadge:{x:p.x-22,y:p.y-22,w:44,h:44,n:S.els.filter(e=>e.kind==='stepbadge').length+1},
|
||
entity:{x:p.x-65,y:p.y-50,w:140,h:110,text:'Entity'},
|
||
lifeline:{x:p.x-50,y:p.y-60,w:110,h:190,text:'Actor'},
|
||
fragment:{x:p.x-90,y:p.y-45,w:190,h:100,text:'loop'},
|
||
}[kind];
|
||
if(!P) return null;
|
||
const el = addEl({kind, ...P});
|
||
S.selId = el.id; render();
|
||
return el;
|
||
}
|
||
function focusEl(el){
|
||
if(el.kind!=='text' && el.kind!=='sticky') return;
|
||
const node = world.querySelector(`[data-id="${el.id}"] .et`);
|
||
if(node){ node.focus(); document.execCommand('selectAll', false, null); }
|
||
}
|
||
function onElPointer(el, ev){
|
||
ev.stopPropagation();
|
||
if(S.viewOnly) return;
|
||
if(S.tool==='eraser'){ S.els = S.els.filter(x=>x.id!==el.id); S.selId=null; history(); if(!S.lock) setTool('select'); toast('Deleted'); return; }
|
||
if(S.tool==='select'){
|
||
S.selId = el.id; render();
|
||
const start = toWorld(ev), ox = el.x, oy = el.y;
|
||
const move = e2 => { const p = toWorld(e2); el.x = ox + p.x - start.x; el.y = oy + p.y - start.y;
|
||
const n = world.querySelector(`[data-id="${el.id}"]`); if(n){ n.style.left = el.x+'px'; n.style.top = el.y+'px'; } };
|
||
const up = () => { window.removeEventListener('pointermove',move); window.removeEventListener('pointerup',up); history(); };
|
||
window.addEventListener('pointermove',move); window.addEventListener('pointerup',up);
|
||
}
|
||
}
|
||
|
||
/* ================================================================
|
||
ZOOM
|
||
================================================================ */
|
||
function setZoom(z, anchor){
|
||
z = Math.min(4, Math.max(.25, z));
|
||
if(anchor){
|
||
const r = stage.getBoundingClientRect();
|
||
const wx = (anchor.x-r.left-S.pan.x)/S.zoom, wy = (anchor.y-r.top-S.pan.y)/S.zoom;
|
||
S.pan.x = anchor.x-r.left - wx*z; S.pan.y = anchor.y-r.top - wy*z;
|
||
}
|
||
S.zoom = z; applyView();
|
||
}
|
||
$('zoomIn').onclick = () => setZoom(S.zoom+.1);
|
||
$('zoomOut').onclick = () => setZoom(S.zoom-.1);
|
||
$('zoomPct').onclick = () => { S.zoom=1; S.pan={x:80,y:80}; applyView(); toast('Zoom reset to 100%'); };
|
||
$('zoomFit').onclick = zoomToFit;
|
||
function zoomToFit(){
|
||
if(!S.els.length){ S.zoom=1; S.pan={x:80,y:80}; applyView(); return; }
|
||
const xs = S.els.map(e=>e.x), ys = S.els.map(e=>e.y);
|
||
const x2 = S.els.map(e=>e.x+e.w), y2 = S.els.map(e=>e.y+e.h);
|
||
const bx0=Math.min(...xs), by0=Math.min(...ys), bw=Math.max(...x2)-bx0, bh=Math.max(...y2)-by0;
|
||
const pad=90, vw=innerWidth-pad*2, vh=innerHeight-pad*2;
|
||
const z = Math.min(1.6, Math.max(.25, Math.min(vw/Math.max(bw,60), vh/Math.max(bh,60))));
|
||
S.zoom = z;
|
||
S.pan.x = pad + (vw-bw*z)/2 - bx0*z;
|
||
S.pan.y = pad + (vh-bh*z)/2 - by0*z;
|
||
applyView(); toast('Zoomed to fit');
|
||
}
|
||
|
||
/* ================================================================
|
||
UNDO / REDO
|
||
================================================================ */
|
||
function undo(){
|
||
if(!S.past.length) return;
|
||
S.future.push(JSON.stringify(S.els));
|
||
S.els = JSON.parse(S.past.pop()); S.selId=null; render();
|
||
}
|
||
function redo(){
|
||
if(!S.future.length) return;
|
||
S.past.push(JSON.stringify(S.els));
|
||
S.els = JSON.parse(S.future.pop()); S.selId=null; render();
|
||
}
|
||
$('undoBtn').onclick = undo; $('redoBtn').onclick = redo;
|
||
|
||
/* ================================================================
|
||
MENUS / POPUPS
|
||
================================================================ */
|
||
let openMenu = null, openPop = null;
|
||
function closeAllMenus(){ closeMenu(); closePops(); closeDots(); }
|
||
function closeMenu(){ $('mainMenu').hidden = true; $('subExport').hidden = true; $('subView').hidden = true; openMenu=null; }
|
||
function closePops(){ ['popShapes','popConnect','popShapesM','popConnectM'].forEach(id => $(id).hidden = true); openPop=null; hideTip(); }
|
||
function closeDots(){ $('dotsMenu').hidden = true; }
|
||
|
||
$('hamBtn').addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
closePops(); $('dotsMenu').hidden = true;
|
||
$('mainMenu').hidden = !$('mainMenu').hidden;
|
||
});
|
||
document.querySelectorAll('[data-sub]').forEach(b => b.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const t = b.dataset.sub==='export' ? $('subExport') : $('subView');
|
||
const willOpen = t.hidden;
|
||
$('subExport').hidden = true; $('subView').hidden = true;
|
||
t.hidden = !willOpen;
|
||
}));
|
||
$('dotsBtn').addEventListener('click', e => {
|
||
e.stopPropagation(); closeMenu(); closePops();
|
||
$('dotsMenu').hidden = !$('dotsMenu').hidden;
|
||
});
|
||
function togglePop(id, btn, up=false){
|
||
const p = $(id), willOpen = p.hidden;
|
||
closePops(); closeMenu(); $('dotsMenu').hidden = true;
|
||
if(willOpen){
|
||
const r = btn.getBoundingClientRect();
|
||
p.hidden = false;
|
||
if(up){ p.style.left = Math.min(r.left, innerWidth - p.offsetWidth - 8)+'px'; p.style.top = (r.top - p.offsetHeight - 10)+'px'; }
|
||
else { p.style.left = (r.right + 10)+'px'; p.style.top = Math.min(r.top, innerHeight - p.offsetHeight - 12)+'px'; }
|
||
openPop = id;
|
||
}
|
||
}
|
||
document.addEventListener('pointerdown', e => {
|
||
if(!e.target.closest('#mainMenu,#hamBtn,.sub-fly')) closeMenu();
|
||
if(!e.target.closest('#dotsMenu,#dotsBtn')) $('dotsMenu').hidden = true;
|
||
if(!e.target.closest('#popShapes,#popConnect,#popShapesM,#popConnectM,#moreShapesBtn,#connectorBtn,#moreShapesBtnM,#connectorBtnM')) closePops();
|
||
if(dl.open && !e.target.closest('#drawer,#allToolsBtn,#allToolsBtnM')) closeDrawer();
|
||
});
|
||
|
||
/* menu actions */
|
||
document.querySelectorAll('#mainMenu [data-act], #dotsMenu [data-act]').forEach(b => b.addEventListener('click', () => {
|
||
const a = b.dataset.act; closeAllMenus();
|
||
({ 'new':() => { S.els=[]; S.selId=null; history(); toast('New canvas'); $('docName').textContent='Untitled canvas'; },
|
||
'canvases':() => toast('My canvases — sign in to sync'),
|
||
'open':() => toast('Open… (.koboyo / .json)'),
|
||
'import':() => toast('Import — drop a file anywhere'),
|
||
'save':() => toast('Saved on this device ✓'),
|
||
'expng':() => toast('Exported as PNG ✓'),
|
||
'exsvg':() => toast('Exported as SVG ✓'),
|
||
'logout':() => toast('Logged out'),
|
||
'undo':undo, 'redo':redo, 'fit':zoomToFit,
|
||
'ai':() => openPanel('panelAI'), 'slides':() => openPanel('panelSlides'), 'code':() => openPanel('panelCode'),
|
||
})[a]?.();
|
||
}));
|
||
document.querySelectorAll('#mainMenu [data-toggle]').forEach(b => b.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const t = b.dataset.toggle;
|
||
if(t==='dark') setDark(!S.dark);
|
||
if(t==='zen') setZen(!S.zen);
|
||
if(t==='viewonly') setViewOnly(!S.viewOnly);
|
||
}));
|
||
function setDark(v){
|
||
S.dark=v; document.documentElement.classList.toggle('dark',v);
|
||
$('swDark').classList.toggle('on',v); render(); applyView();
|
||
toast(v?'Dark mode':'Light mode');
|
||
}
|
||
function setZen(v){ S.zen=v; document.body.classList.toggle('zen',v); $('swZen').classList.toggle('on',v); if(v) closeAllMenus(); }
|
||
function setViewOnly(v){ S.viewOnly=v; document.body.classList.toggle('view-only',v); $('swView').classList.toggle('on',v); toast(v?'View-only mode':'Editing enabled'); }
|
||
$('zenExit').onclick = () => setZen(false);
|
||
|
||
/* ================================================================
|
||
SIDE PANELS
|
||
================================================================ */
|
||
const PANELS = ['panelSlides','panelCode','panelAI','panelComments'];
|
||
function openPanel(id){
|
||
const wasHidden = $(id).hidden;
|
||
PANELS.forEach(p => $(p).hidden = true);
|
||
$(id).hidden = !wasHidden;
|
||
$('slidesBtn').classList.toggle('on', !$('panelSlides').hidden);
|
||
$('codeBtn').classList.toggle('on', !$('panelCode').hidden);
|
||
$('aiBtn').classList.toggle('on', !$('panelAI').hidden);
|
||
$('commentsBtn').classList.toggle('on', !$('panelComments').hidden);
|
||
}
|
||
$('slidesBtn').onclick = () => openPanel('panelSlides');
|
||
$('codeBtn').onclick = () => openPanel('panelCode');
|
||
$('aiBtn').onclick = () => openPanel('panelAI');
|
||
$('commentsBtn').onclick = () => openPanel('panelComments');
|
||
document.querySelectorAll('[data-closepanel]').forEach(b => b.onclick = () => { b.closest('.side-panel').hidden = true;
|
||
['slidesBtn','codeBtn','aiBtn','commentsBtn'].forEach(i => $(i).classList.remove('on')); });
|
||
|
||
/* misc chrome */
|
||
$('loginBtn').onclick = () => toast('Log in to save & sync canvases');
|
||
$('shareBtn').onclick = () => toast('Share link copied ✓');
|
||
$('docName').addEventListener('keydown', e => { if(e.key==='Enter'){ e.preventDefault(); $('docName').blur(); toast('Renamed ✓'); }});
|
||
|
||
function toast(m){ const t=$('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),1900); }
|
||
document.querySelectorAll('[data-toast]').forEach(b => b.addEventListener('click', () => toast(b.dataset.toast)));
|
||
|
||
/* ================================================================
|
||
ALL-TOOLS DRAWER — registry mirrors koboyo's tools bundle:
|
||
Basic · AI · Shapes · Connectors · Text & media ·
|
||
Charts & data · Frames · UI controls, with persisted Recents.
|
||
================================================================ */
|
||
const ICO = inner => `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
||
const TOOL_ICONS = {
|
||
select:ICO('<path d="M5 3.5 18.5 12l-6.2 1.2L9 19.5 5 3.5Z"/>'),
|
||
hand:ICO('<path d="M8 11V5.5a1.5 1.5 0 0 1 3 0V11m0-6.5v-1a1.5 1.5 0 0 1 3 0V11m0-5.5a1.5 1.5 0 0 1 3 0v7m0-3a1.5 1.5 0 0 1 3 0v5c0 3.5-2.5 6.5-6.5 6.5-3 0-4.6-1.2-6.3-3.6L4.6 13c-.7-1-.2-2.2.8-2.5.7-.2 1.5 0 2 .6L8 12.6"/>'),
|
||
lasso:ICO('<path d="M6.5 5.5a8.5 6.5 0 1 0 .5 13" stroke-dasharray="3.5 3"/><path d="m15 18 3 3m5-5-3 3 3 3"/>'),
|
||
eraser:ICO('<path d="m7 20-3.3-3.3a2 2 0 0 1 0-2.8L13 4.6a2 2 0 0 1 2.8 0l3.6 3.6a2 2 0 0 1 0 2.8L12 18.4M7 20h13M7 20l4-4"/>'),
|
||
laser:ICO('<circle cx="7" cy="17" r="2.2" fill="currentColor" stroke="none"/><path d="M7 17 20 4"/>'),
|
||
arrow:ICO('<path d="M5 19 17 7m0 0h-6m6 0v6"/>'),
|
||
line:ICO('<path d="M5 19 19 5"/>'),
|
||
polyline:ICO('<path d="M4 19 10 9l4 6 6-10"/>'),
|
||
text:ICO('<path d="M5 6.5V5h14v1.5M12 5v14m-3 0h6"/>'),
|
||
sticky:ICO('<path d="M5 4h14v11l-5 5H5V4Z"/><path d="M19 15h-5v5"/>'),
|
||
draw:ICO('<path d="m14.5 6 3.5 3.5L8.5 19H5v-3.5L14.5 6Z"/><path d="m13 7.5 3.5 3.5M5 19l2.5-2.5"/>'),
|
||
highlight:ICO('<path d="m9 15 7.5-7.5a1.8 1.8 0 0 1 2.5 0l1.5 1.5a1.8 1.8 0 0 1 0 2.5L13 19l-5.5 1.5L9 15Z"/><path d="M4.5 21.5h6"/>'),
|
||
image:ICO('<rect x="4" y="5" width="16" height="14" rx="2.5"/><circle cx="9" cy="10" r="1.6"/><path d="m5.5 17 4.5-4 3 3 2.5-2 3 3"/>'),
|
||
icon:ICO('<path d="m12 3 2.1 4.9 5.3.5-4 3.6 1.2 5.2-4.6-2.7-4.6 2.7 1.2-5.2-4-3.6 5.3-.5L12 3Z"/>'),
|
||
stepbadge:ICO('<circle cx="12" cy="12" r="8.5"/><path d="M10 9.2 12 8v8" stroke-width="1.9"/>'),
|
||
entity:ICO('<rect x="5" y="4" width="14" height="16" rx="2"/><path d="M5 9.5h14M5 14.5h14"/>'),
|
||
lifeline:ICO('<rect x="7" y="4" width="10" height="5" rx="1.5"/><path d="M12 11v9" stroke-dasharray="3 3"/>'),
|
||
fragment:ICO('<rect x="4" y="6" width="16" height="12" rx="1.5"/><path d="M4 6v5h7V4.5"/>'),
|
||
table:ICO('<rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M3.5 9.5h17M3.5 14.5h17M12 9.5V20"/>'),
|
||
'chart-bar':ICO('<path d="M5 19v-6M10 19V5M15 19v-4M20 19V9"/>'),
|
||
'chart-line':ICO('<path d="M3 17l5-6 4 3 6-8 3 3"/>'),
|
||
'chart-area':ICO('<path d="M3 16l4.5-6 4 3.5 5.5-7.5 4 4.5V20H3Z"/>'),
|
||
'chart-pie':ICO('<circle cx="11" cy="13" r="8"/><path d="M11 5v8h8"/>'),
|
||
'chart-donut':ICO('<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3.5"/>'),
|
||
'chart-scatter':ICO('<circle cx="6" cy="16" r="1.6" fill="currentColor" stroke="none"/><circle cx="11" cy="9" r="1.6" fill="currentColor" stroke="none"/><circle cx="16" cy="14" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="7" r="1.6" fill="currentColor" stroke="none"/>'),
|
||
'chart-radar':ICO('<polygon points="12 3 20 9 17 19 7 19 4 9"/><path d="M12 8.5l3.5 2.8-1.3 4.2h-4.4l-1.3-4.2Z"/>'),
|
||
'chart-quadrant':ICO('<path d="M12 3v18M3 12h18"/><circle cx="8" cy="8" r="1.8" fill="currentColor" stroke="none"/><circle cx="16" cy="15" r="1.8" fill="currentColor" stroke="none"/>'),
|
||
'frame-simple':ICO('<rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M3.5 4v5h8V2.5"/>'),
|
||
'frame-browser':ICO('<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><path d="M3 9.5h18"/><circle cx="7" cy="7" r=".8" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7" r=".8" fill="currentColor" stroke="none"/>'),
|
||
'frame-phone':ICO('<rect x="7" y="2.5" width="10" height="19" rx="2.5"/><path d="M10.5 5h3"/>'),
|
||
'ask-ai':ICO('<path d="M14.6 4.2 15.7 8l3.8 1.1-3.8 1.1-1.1 3.8-1.1-3.8L9.7 9.1 13.5 8l1.1-3.8Z"/><path d="M18.7 14.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z"/>'),
|
||
};
|
||
const GICON = {
|
||
'Basic':TOOL_ICONS.select,'AI':TOOL_ICONS['ask-ai'],'Connectors':TOOL_ICONS.arrow,
|
||
'Text & media':TOOL_ICONS.text,'UI controls':ICO('<rect x="3" y="8" width="18" height="9" rx="4.5"/><circle cx="8.5" cy="12.5" r="2"/>'),
|
||
'Charts & data':TOOL_ICONS['chart-bar'],'Frames':TOOL_ICONS['frame-simple'],
|
||
};
|
||
function iconFor(t){
|
||
if(t.id==='rectangle'||t.id==='ellipse')
|
||
return ICO(SHAPES24[t.id==='rectangle'?'rectangle':'ellipse']);
|
||
if(t.id.startsWith('shape-')) return ICO(SHAPES24[t.id.slice(6)]||SHAPES24.rectangle);
|
||
return TOOL_ICONS[t.id] || GICON[t.group] || TOOL_ICONS.select;
|
||
}
|
||
|
||
const TOOLS = [];
|
||
const RT = (id,label,group,hint,kw,run) => TOOLS.push({id,label,group,hint,kw,run});
|
||
RT('select','Select','Basic','V','',()=>setTool('select'));
|
||
RT('hand','Hand','Basic','H','pan',()=>setTool('hand'));
|
||
RT('lasso','Lasso select','Basic','Q','freehand select',()=>setTool('lasso'));
|
||
RT('eraser','Eraser','Basic','E','',()=>setTool('eraser'));
|
||
RT('laser','Laser pointer','Basic','K','present',()=>setTool('laser'));
|
||
RT('ask-ai','Ask AI','AI','','assistant chat sparkles help',()=>openPanel('panelAI'));
|
||
RT('ai-diagram','Generate diagram with AI','AI','','assistant architecture flowchart create',()=>{openPanel('panelAI');toast('Prompt: “Draw a diagram of ”')});
|
||
RT('ai-deck','Generate slides with AI','AI','','assistant deck presentation create',()=>{openPanel('panelAI');toast('Prompt: “Create a short deck about ”')});
|
||
RT('ai-image','Generate image with AI','AI','','assistant picture photo create',()=>{openPanel('panelAI');toast('Prompt: “Generate an image of ”')});
|
||
RT('rectangle','Rectangle','Shapes','R','box square',()=>setTool('rect'));
|
||
RT('ellipse','Ellipse','Shapes','O','circle oval',()=>setTool('ellipse'));
|
||
SHAPE_KINDS.forEach(([k,label])=>RT('shape-'+k,label,'Shapes','','shape',()=>setTool('gshape',{g:k})));
|
||
RT('stepbadge','Step badge','Shapes','B','number step',()=>setTool('stepbadge'));
|
||
RT('entity','Class / entity','Shapes','','erd uml class table attributes fields',()=>setTool('entity'));
|
||
RT('lifeline','Lifeline','Shapes','','sequence uml participant',()=>setTool('lifeline'));
|
||
RT('fragment','Fragment','Shapes','','sequence uml combined opt alt loop par frame container',()=>setTool('fragment'));
|
||
RT('arrow','Arrow','Connectors','A','connector',()=>setTool('connector',{conn:'arrow'}));
|
||
RT('line','Line','Connectors','L','connector',()=>setTool('connector',{conn:'line'}));
|
||
RT('polyline','Multi-point line','Connectors','C','connector elbow path',()=>setTool('connector',{conn:'polyline'}));
|
||
RT('text','Text','Text & media','T','',()=>setTool('text'));
|
||
RT('sticky','Sticky note','Text & media','N','note',()=>setTool('sticky'));
|
||
RT('draw','Draw','Text & media','P','pen pencil freehand',()=>setTool('draw'));
|
||
RT('highlight','Highlighter','Text & media','⇧P','marker highlight pen freehand',()=>setTool('highlight'));
|
||
RT('image','Image','Text & media','I','photo picture',()=>setTool('image'));
|
||
RT('icon','Icons','Text & media','S','emoji sticker symbol',()=>setTool('icons'));
|
||
RT('table','Table','Charts & data','','grid data',()=>insertAtCenter('table'));
|
||
[['bar','Bar chart'],['line','Line chart'],['area','Area chart'],['scatter','Scatter chart'],
|
||
['pie','Pie chart'],['donut','Donut chart'],['radar','Radar chart'],['quadrant','Quadrant chart']]
|
||
.forEach(([k,l])=>RT('chart-'+k,l,'Charts & data','','chart graph data',()=>insertAtCenter('chart',{chart:k})));
|
||
RT('frame-simple','Frame','Frames','F','frame container device mockup',()=>insertAtCenter('frame',{frame:'simple'}));
|
||
RT('frame-browser','Browser window','Frames','','frame container device mockup web',()=>insertAtCenter('frame',{frame:'browser'}));
|
||
RT('frame-phone','Phone frame','Frames','','frame container device mockup mobile',()=>insertAtCenter('frame',{frame:'phone'}));
|
||
[['button','Button'],['input','Input'],['search','Search field'],['checkbox','Checkbox'],['radio','Radio'],
|
||
['toggle','Toggle'],['dropdown','Dropdown'],['slider','Slider'],['navbar','Navbar'],['tabs','Tabs'],
|
||
['image','Image placeholder'],['avatar','Avatar'],['heading','Heading'],['paragraph','Paragraph'],
|
||
['divider','Divider'],['link','Link'],['textarea','Text area'],['stepper','Number stepper'],['badge','Badge'],
|
||
['progress','Progress bar'],['breadcrumbs','Breadcrumbs'],['pagination','Pagination'],['rating','Rating'],
|
||
['card','Card'],['list','List'],['alert','Alert'],['modal','Modal dialog'],['menu','Menu'],['tooltip','Tooltip'],
|
||
['accordion','Accordion'],['datepicker','Date picker'],['sidebar','Sidebar'],['scrollbar','Scrollbar'],
|
||
['segmented','Segmented control'],['tabbar','Bottom tab bar'],['statusbar','Status bar'],['fab','Floating button']]
|
||
.forEach(([k,l])=>RT('ctrl-'+k,l,'UI controls','','wireframe mockup ui widget',()=>insertAtCenter('ctrl',{ctrl:k})));
|
||
|
||
/* recents — persisted, max 8 (mirrors use-recent-list) */
|
||
const RKEY = 'koboyo:recent-tools';
|
||
function getRecent(){ try{ return JSON.parse(localStorage.getItem(RKEY)||'[]').filter(x=>typeof x==='string').slice(0,8); }catch{ return []; } }
|
||
function rememberRecent(id){
|
||
try{ localStorage.setItem(RKEY, JSON.stringify([id,...getRecent().filter(x=>x!==id)].slice(0,8))); }catch{}
|
||
}
|
||
|
||
/* drawer open/close + anchored positioning + keyboard nav */
|
||
let dl = {open:false, items:[], idx:0, q:''};
|
||
const isPhoneMode = () => matchMedia('(max-width:639px)').matches;
|
||
function openDrawer(){
|
||
$('drawer').hidden = false;
|
||
dl.open = true; dl.q = ''; dl.idx = 0;
|
||
$('drawerSearch').value = '';
|
||
buildDrawerList(); positionDrawer();
|
||
addEventListener('resize', positionDrawer);
|
||
if(!matchMedia('(pointer:coarse)').matches) $('drawerSearch').focus();
|
||
}
|
||
function closeDrawer(){
|
||
$('drawer').hidden = true; dl.open = false;
|
||
removeEventListener('resize', positionDrawer);
|
||
}
|
||
function positionDrawer(){
|
||
if(!dl.open) return;
|
||
const d = $('drawer');
|
||
if(isPhoneMode()){
|
||
const bar = $('phoneBar').getBoundingClientRect();
|
||
d.style.left = Math.max(8, bar.left)+'px'; d.style.top = '';
|
||
d.style.bottom = (innerHeight - bar.top + 8)+'px';
|
||
d.style.maxHeight = '55dvh';
|
||
} else {
|
||
const rail = $('toolRail').getBoundingClientRect();
|
||
d.style.left = (rail.right + 8)+'px';
|
||
d.style.top = Math.max(56, Math.min(rail.top, innerHeight - 360))+'px';
|
||
d.style.bottom = '';
|
||
d.style.maxHeight = 'calc(100dvh - 72px)';
|
||
}
|
||
}
|
||
function buildDrawerList(){
|
||
const q = dl.q.trim().toLowerCase();
|
||
let items;
|
||
if(q){
|
||
items = TOOLS.filter(t => (t.label+' '+t.group+' '+(t.kw||'')).toLowerCase().includes(q));
|
||
} else {
|
||
const byId = Object.fromEntries(TOOLS.map(t=>[t.id,t]));
|
||
items = [...getRecent().map(id=>byId[id]).filter(Boolean).map(t=>({...t,group:'Recent'})), ...TOOLS];
|
||
}
|
||
dl.items = items;
|
||
dl.idx = Math.min(dl.idx, Math.max(0, items.length-1));
|
||
const list = $('drawerList'); list.innerHTML = '';
|
||
if(!items.length){ list.innerHTML = '<div class="cmd-empty">No matching tools</div>'; return; }
|
||
items.forEach((t,i) => {
|
||
if(!q && (i===0 || items[i-1].group !== t.group)){
|
||
const h = document.createElement('div'); h.className = 'cmd-group'; h.textContent = t.group;
|
||
list.appendChild(h);
|
||
}
|
||
const b = document.createElement('button');
|
||
b.className = 'cmd-item'; b.dataset.testid = 'tool-'+t.id;
|
||
b.innerHTML = `<span class="ci-ic">${iconFor(t)}</span><span class="ci-lbl">${t.label}</span>${t.hint?`<span class="ci-hint">${t.hint}</span>`:''}`;
|
||
b.addEventListener('pointerenter', () => { const idx = [...list.querySelectorAll('.cmd-item')].indexOf(b); dl.idx = idx; syncActive(); });
|
||
b.addEventListener('click', () => runItem(t));
|
||
list.appendChild(b);
|
||
});
|
||
syncActive();
|
||
}
|
||
function syncActive(){
|
||
const btns = [...$('drawerList').querySelectorAll('.cmd-item')];
|
||
btns.forEach((n,i) => n.classList.toggle('active', i === dl.idx));
|
||
btns[dl.idx]?.scrollIntoView({block:'nearest'});
|
||
}
|
||
function runItem(t){ closeDrawer(); t.run(); rememberRecent(t.id); }
|
||
$('drawerSearch').addEventListener('input', e => { dl.q = e.target.value; dl.idx = 0; buildDrawerList(); });
|
||
$('drawerSearch').addEventListener('keydown', e => {
|
||
if(e.key==='ArrowDown'){ e.preventDefault(); dl.idx = Math.min(dl.idx+1, dl.items.length-1); syncActive(); }
|
||
else if(e.key==='ArrowUp'){ e.preventDefault(); dl.idx = Math.max(dl.idx-1, 0); syncActive(); }
|
||
else if(e.key==='Enter'){ e.preventDefault(); const t = dl.items[dl.idx]; if(t) runItem(t); }
|
||
});
|
||
$('allToolsBtn').onclick = () => dl.open ? closeDrawer() : openDrawer();
|
||
$('allToolsBtnM').onclick = () => dl.open ? closeDrawer() : openDrawer();
|
||
|
||
/* ================================================================
|
||
SHORTCUTS DIALOG
|
||
================================================================ */
|
||
const SC_GROUPS = [
|
||
['Tools',[['/','Search all tools'],['V','Select'],['Q','Lasso select'],['H','Hand (pan)'],['R','Rectangle'],['O','Ellipse'],['A','Arrow'],['L','Line'],['C','Multi-point line'],['P','Draw'],['⇧P','Highlighter'],['T','Text'],['N','Sticky note'],['F','Frame'],['I','Image'],['S','Icons'],['G','Chart'],['B','Step badge'],['E','Eraser'],['⇧C','Comment'],['⌘.','Keep tool active']]],
|
||
['Edit',[['⌘C','Copy'],['⌘X','Cut'],['⌘V','Paste'],['⌘D','Duplicate'],['⌘A','Select all'],['⌫','Delete'],['⌘Z','Undo'],['⌘⇧Z','Redo'],['⌘G','Group'],['⌘⇧G','Ungroup'],['⏎','Edit shape'],['Tab','Connected shape']]],
|
||
['View',[['⌘+','Zoom in'],['⌘−','Zoom out'],['⌘0','Reset zoom'],['⌘1','Zoom to fit'],['⌘F','Find on canvas'],['⌘K','Command palette'],['⌥Z','Zen mode'],['Right-drag','Pan the canvas']]],
|
||
];
|
||
$('scGrid').innerHTML = SC_GROUPS.map(([g,rows]) =>
|
||
`<div class="sc-group"><h3>${g}</h3>${rows.map(([k,l]) => `<div class="sc-row"><span>${l}</span><kbd>${k}</kbd></div>`).join('')}</div>`
|
||
).join('');
|
||
function openShortcuts(){ $('scVeil').hidden=false; $('scDialog').hidden=false; }
|
||
function closeShortcuts(){ $('scVeil').hidden=true; $('scDialog').hidden=true; }
|
||
$('helpFab').onclick = openShortcuts;
|
||
$('scVeil').onclick = closeShortcuts;
|
||
|
||
/* ================================================================
|
||
TOOLTIPS
|
||
================================================================ */
|
||
const tipEl = $('tooltip');
|
||
let tipTimer = null;
|
||
function showTip(html, x, y){
|
||
clearTimeout(tipTimer);
|
||
tipEl.innerHTML = html; tipEl.style.opacity = 1;
|
||
const r = tipEl.getBoundingClientRect();
|
||
tipEl.style.left = Math.min(x, innerWidth - r.width - 8)+'px';
|
||
tipEl.style.top = Math.max(8, y)+'px';
|
||
}
|
||
function hideTip(){ clearTimeout(tipTimer); tipEl.style.opacity = 0; }
|
||
document.querySelectorAll('[data-tip]').forEach(b => {
|
||
b.addEventListener('pointerenter', e => {
|
||
if(matchMedia('(pointer:coarse)').matches || !b.dataset.tip) return;
|
||
tipTimer = setTimeout(() => {
|
||
const [label,kbd] = b.dataset.tip.split('|');
|
||
const r = b.getBoundingClientRect();
|
||
showTip(label + (kbd?` <kbd>${kbd}</kbd>`:''), r.right + 10, r.top + r.height/2 - 13);
|
||
}, 380);
|
||
});
|
||
b.addEventListener('pointerleave', hideTip);
|
||
b.addEventListener('pointerdown', hideTip);
|
||
});
|
||
|
||
/* ================================================================
|
||
KEYBOARD
|
||
================================================================ */
|
||
document.addEventListener('keydown', e => {
|
||
const typing = /^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
|
||
const mod = e.metaKey || e.ctrlKey;
|
||
if(e.key==='Escape'){
|
||
if(!$('drawer').hidden) return closeDrawer();
|
||
if(!$('scDialog').hidden) return closeShortcuts();
|
||
closeAllMenus();
|
||
if(S.zen) return setZen(false);
|
||
S.selId=null; render(); return;
|
||
}
|
||
if(typing) return;
|
||
if(mod && e.key.toLowerCase()==='z'){ e.preventDefault(); e.shiftKey?redo():undo(); return; }
|
||
if(mod && e.key.toLowerCase()==='s'){ e.preventDefault(); toast('Saved on this device ✓'); return; }
|
||
if(mod && e.key==='.'){ e.preventDefault(); $('lockBtn').click(); return; }
|
||
if(mod) return;
|
||
if(e.key==='Alt' ) return;
|
||
const k = e.key.toLowerCase();
|
||
if(e.altKey && k==='z'){ e.preventDefault(); setZen(!S.zen); return; }
|
||
if(e.key==='/'){ e.preventDefault(); dl.open ? closeDrawer() : openDrawer(); return; }
|
||
if(e.key==='?'){ openShortcuts(); return; }
|
||
if(e.key==='Delete'||e.key==='Backspace'){
|
||
if(S.selId!=null && !S.viewOnly){ S.els = S.els.filter(x=>x.id!==S.selId); S.selId=null; history(); } return;
|
||
}
|
||
if(k==='p' && e.shiftKey) return setTool('highlight');
|
||
if(k==='q') return setTool('lasso');
|
||
if(k==='k') return setTool('laser');
|
||
const map = {v:'select',h:'hand',r:'rect',o:'ellipse',t:'text',n:'sticky',p:'draw',i:'image',s:'icons',e:'eraser',b:'stepbadge'};
|
||
if(S.viewOnly && !['v','h'].includes(k)) return;
|
||
if(map[k]) return setTool(map[k]);
|
||
if(k==='a') return setTool('connector',{conn:'arrow'});
|
||
if(k==='l') return setTool('connector',{conn:'line'});
|
||
if(k==='c') return setTool('connector',{conn:'polyline'});
|
||
});
|
||
|
||
/* ================================================================
|
||
RESPONSIVE HYGIENE — when the 640px split is crossed, close any
|
||
transient UI so a desktop flyout can't linger in phone mode
|
||
================================================================ */
|
||
matchMedia('(max-width:639px)').addEventListener('change', () => {
|
||
closeAllMenus();
|
||
hideTip();
|
||
});
|
||
|
||
/* ================================================================
|
||
BOOT — demo sketch so panels/tools have context, like the site's
|
||
first-run: keep canvas empty to show the onboarding hints.
|
||
================================================================ */
|
||
setTool('select');
|
||
applyView();
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|