mirror of
https://github.com/github/awesome-copilot.git
synced 2026-03-12 04:05:12 +00:00
* Add napkin plugin: visual whiteboard collaboration for Copilot CLI Napkin opens an interactive HTML whiteboard in the user's browser where they can draw, sketch, and add sticky notes. When ready, they click 'Share with Copilot' which exports a PNG snapshot. The Copilot CLI agent reads the PNG via the view tool, and the multimodal AI model interprets the drawings, spatial layout, and text content — responding conversationally as a collaborator. Built for non-technical users: lawyers, PMs, business stakeholders, designers, and anyone who thinks better visually. Includes: - Self-contained HTML whiteboard (zero external dependencies) - Shape recognition (wobbly shapes snap to clean versions) - Freehand drawing, sticky notes, arrows, text labels - Auto-save to localStorage - SKILL.md with agent instructions - SVG visual guides for step-by-step README documentation - Plugin manifest for Copilot CLI installation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: roundRect compat, cross-platform shortcuts, eraser undo - Add safeRoundRect() fallback for browsers without CanvasRenderingContext2D.roundRect() - Update undo/redo button titles to show Ctrl/Cmd instead of Mac-only Cmd - Fix eraser undo by capturing state before first erase mutation per gesture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update skills/napkin/templates/napkin.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update plugins/napkin/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update skills/napkin/templates/napkin.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update skills/napkin/templates/napkin.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update skills/napkin/templates/napkin.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add missing Line (L) and Eraser (E) keyboard shortcuts to README The in-app shortcuts panel and keyMap handler both support L for Line and E for Eraser, but the README keyboard shortcuts table was missing both entries. Adds them to match the actual implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Combine docs/ and templates/ into assets/ per agentskills.io spec Moves skills/napkin/docs/*.svg and skills/napkin/templates/napkin.html into skills/napkin/assets/ to align with the Agent Skills specification. Updates all path references in SKILL.md, README.md, and generated docs. Addresses review feedback from PR #929. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Dan Velton <dvelton@Dans-MacBook-Pro.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2020 lines
61 KiB
HTML
2020 lines
61 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Napkin — Whiteboard for Copilot</title>
|
|
<style>
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html, body {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: #f5f5f5;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
/* ── Toolbar ───────────────────────────────────────────────────── */
|
|
#toolbar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 72px;
|
|
background: #fafafa;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
gap: 4px;
|
|
z-index: 1000;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
}
|
|
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 0 6px;
|
|
}
|
|
|
|
.toolbar-group + .toolbar-group {
|
|
border-left: 1px solid #e0e0e0;
|
|
margin-left: 4px;
|
|
padding-left: 10px;
|
|
}
|
|
|
|
.tool-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 56px;
|
|
height: 56px;
|
|
border: 2px solid transparent;
|
|
border-radius: 10px;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
padding: 4px 2px 2px;
|
|
}
|
|
|
|
.tool-btn:hover {
|
|
background: #eee;
|
|
}
|
|
|
|
.tool-btn.active {
|
|
background: #e3f2fd;
|
|
border-color: #1e88e5;
|
|
}
|
|
|
|
.tool-btn .icon {
|
|
font-size: 20px;
|
|
line-height: 1;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.tool-btn .label {
|
|
font-size: 9px;
|
|
color: #666;
|
|
margin-top: 2px;
|
|
white-space: nowrap;
|
|
font-weight: 500;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.tool-btn.active .label {
|
|
color: #1e88e5;
|
|
}
|
|
|
|
/* Color picker */
|
|
.color-picker {
|
|
display: flex;
|
|
gap: 3px;
|
|
align-items: center;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.color-swatch {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
border: 2px solid #ddd;
|
|
cursor: pointer;
|
|
transition: transform 0.1s ease;
|
|
}
|
|
|
|
.color-swatch:hover {
|
|
transform: scale(1.15);
|
|
}
|
|
|
|
.color-swatch.active {
|
|
border-color: #333;
|
|
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #333;
|
|
}
|
|
|
|
/* Stroke width buttons */
|
|
.stroke-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 2px solid transparent;
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.stroke-btn:hover {
|
|
background: #eee;
|
|
}
|
|
|
|
.stroke-btn.active {
|
|
background: #e3f2fd;
|
|
border-color: #1e88e5;
|
|
}
|
|
|
|
.stroke-btn .stroke-line {
|
|
background: #333;
|
|
border-radius: 4px;
|
|
width: 20px;
|
|
}
|
|
|
|
.stroke-btn .label {
|
|
font-size: 8px;
|
|
color: #888;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Share button */
|
|
.share-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 20px;
|
|
background: #0d9488;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease, transform 0.1s ease;
|
|
margin-left: auto;
|
|
white-space: nowrap;
|
|
box-shadow: 0 2px 8px rgba(13,148,136,0.3);
|
|
font-family: inherit;
|
|
}
|
|
|
|
.share-btn:hover {
|
|
background: #0f766e;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.share-btn:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.share-btn .icon {
|
|
font-size: 18px;
|
|
}
|
|
|
|
/* Help button */
|
|
.help-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
border: 2px solid #ccc;
|
|
background: #fff;
|
|
color: #888;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-left: 8px;
|
|
flex-shrink: 0;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.help-btn:hover {
|
|
border-color: #999;
|
|
color: #555;
|
|
}
|
|
|
|
/* ── Canvas area ───────────────────────────────────────────────── */
|
|
#canvas-container {
|
|
position: fixed;
|
|
top: 72px;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
overflow: hidden;
|
|
background: #f0f0f0;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
#canvas-container.panning {
|
|
cursor: grab;
|
|
}
|
|
|
|
#canvas-container.panning:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
#drawing-canvas {
|
|
position: absolute;
|
|
background: #fff;
|
|
box-shadow: 0 2px 20px rgba(0,0,0,0.08);
|
|
}
|
|
|
|
/* ── Sticky notes ──────────────────────────────────────────────── */
|
|
.sticky-note {
|
|
position: absolute;
|
|
min-width: 140px;
|
|
min-height: 100px;
|
|
border-radius: 4px;
|
|
box-shadow: 2px 3px 12px rgba(0,0,0,0.12), 0 1px 4px rgba(0,0,0,0.06);
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 500;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.sticky-note .note-header {
|
|
height: 24px;
|
|
border-radius: 4px 4px 0 0;
|
|
cursor: move;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
padding: 0 4px;
|
|
flex-shrink: 0;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.sticky-note .note-delete {
|
|
width: 18px;
|
|
height: 18px;
|
|
border: none;
|
|
background: rgba(0,0,0,0.15);
|
|
color: rgba(0,0,0,0.5);
|
|
border-radius: 50%;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.sticky-note:hover .note-delete {
|
|
opacity: 1;
|
|
}
|
|
|
|
.sticky-note .note-delete:hover {
|
|
background: rgba(0,0,0,0.3);
|
|
color: rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.sticky-note .note-body {
|
|
flex: 1;
|
|
padding: 8px 12px 12px;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
outline: none;
|
|
cursor: text;
|
|
overflow-wrap: break-word;
|
|
word-break: break-word;
|
|
white-space: pre-wrap;
|
|
border-radius: 0 0 4px 4px;
|
|
min-height: 76px;
|
|
}
|
|
|
|
.sticky-note .note-resize {
|
|
position: absolute;
|
|
bottom: 0;
|
|
right: 0;
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: nwse-resize;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.sticky-note:hover .note-resize {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.sticky-note .note-resize::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 3px;
|
|
right: 3px;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-right: 2px solid rgba(0,0,0,0.3);
|
|
border-bottom: 2px solid rgba(0,0,0,0.3);
|
|
}
|
|
|
|
/* Sticky note colors */
|
|
.sticky-yellow { background: #fff9c4; }
|
|
.sticky-yellow .note-header { background: #fff176; }
|
|
.sticky-pink { background: #fce4ec; }
|
|
.sticky-pink .note-header { background: #f48fb1; }
|
|
.sticky-blue { background: #e3f2fd; }
|
|
.sticky-blue .note-header { background: #90caf9; }
|
|
.sticky-green { background: #e8f5e9; }
|
|
.sticky-green .note-header { background: #a5d6a7; }
|
|
|
|
/* Sticky note color picker in toolbar */
|
|
.note-color-picker {
|
|
display: none;
|
|
position: absolute;
|
|
top: 60px;
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
padding: 8px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
|
gap: 6px;
|
|
z-index: 1001;
|
|
}
|
|
|
|
.note-color-picker.show {
|
|
display: flex;
|
|
}
|
|
|
|
.note-color-opt {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 6px;
|
|
border: 2px solid #ddd;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.note-color-opt:hover {
|
|
border-color: #999;
|
|
}
|
|
|
|
/* ── Text labels on canvas ─────────────────────────────────────── */
|
|
.canvas-text-label {
|
|
position: absolute;
|
|
font-size: 16px;
|
|
color: #333;
|
|
outline: none;
|
|
cursor: text;
|
|
padding: 2px 4px;
|
|
min-width: 20px;
|
|
min-height: 20px;
|
|
white-space: pre-wrap;
|
|
z-index: 400;
|
|
border: 1px dashed transparent;
|
|
border-radius: 3px;
|
|
font-family: inherit;
|
|
background: transparent;
|
|
}
|
|
|
|
.canvas-text-label:focus {
|
|
border-color: #90caf9;
|
|
background: rgba(255,255,255,0.85);
|
|
}
|
|
|
|
/* ── Overlays ──────────────────────────────────────────────────── */
|
|
.overlay-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.45);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.overlay-backdrop.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.overlay-card {
|
|
background: #fff;
|
|
border-radius: 16px;
|
|
padding: 40px 44px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
box-shadow: 0 16px 48px rgba(0,0,0,0.18);
|
|
text-align: center;
|
|
}
|
|
|
|
.overlay-card h1 {
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
color: #222;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.overlay-card .subtitle {
|
|
font-size: 15px;
|
|
color: #666;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.overlay-card .steps {
|
|
text-align: left;
|
|
margin: 0 auto 28px;
|
|
max-width: 380px;
|
|
}
|
|
|
|
.overlay-card .steps .step {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-bottom: 14px;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
color: #444;
|
|
}
|
|
|
|
.overlay-card .steps .step-num {
|
|
flex-shrink: 0;
|
|
width: 26px;
|
|
height: 26px;
|
|
background: #0d9488;
|
|
color: #fff;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.overlay-card .cta-btn {
|
|
display: inline-block;
|
|
padding: 14px 32px;
|
|
background: #0d9488;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.overlay-card .cta-btn:hover {
|
|
background: #0f766e;
|
|
}
|
|
|
|
/* Share confirmation */
|
|
.overlay-card .confirm-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.overlay-card .confirm-detail {
|
|
text-align: left;
|
|
background: #f5f5f5;
|
|
border-radius: 10px;
|
|
padding: 16px 20px;
|
|
margin: 16px 0 24px;
|
|
font-size: 13px;
|
|
line-height: 1.7;
|
|
color: #555;
|
|
}
|
|
|
|
.overlay-card .confirm-detail .clipboard-hint {
|
|
display: inline-block;
|
|
background: #e8f5e9;
|
|
color: #2e7d32;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* ── Keyboard shortcuts panel ──────────────────────────────────── */
|
|
.shortcuts-panel {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
right: 16px;
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 16px 20px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
|
|
z-index: 1001;
|
|
font-size: 12px;
|
|
display: none;
|
|
min-width: 220px;
|
|
}
|
|
|
|
.shortcuts-panel.show {
|
|
display: block;
|
|
}
|
|
|
|
.shortcuts-panel h3 {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
margin-bottom: 10px;
|
|
color: #333;
|
|
}
|
|
|
|
.shortcuts-panel .shortcut-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 3px 0;
|
|
color: #555;
|
|
}
|
|
|
|
.shortcuts-panel .shortcut-row kbd {
|
|
background: #f0f0f0;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
padding: 1px 6px;
|
|
font-family: monospace;
|
|
font-size: 11px;
|
|
color: #444;
|
|
}
|
|
|
|
.shortcuts-panel .close-shortcuts {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 10px;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
color: #999;
|
|
}
|
|
|
|
/* ── Zoom indicator ────────────────────────────────────────────── */
|
|
.zoom-indicator {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
left: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
padding: 6px 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
font-size: 12px;
|
|
color: #555;
|
|
z-index: 1001;
|
|
}
|
|
|
|
.zoom-indicator button {
|
|
width: 26px;
|
|
height: 26px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #555;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.zoom-indicator button:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
/* ── Toast notification ────────────────────────────────────────── */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 60px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
background: #333;
|
|
color: #fff;
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
opacity: 0;
|
|
transition: all 0.3s ease;
|
|
z-index: 9998;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.toast.show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ── Toolbar ──────────────────────────────────────────────────── -->
|
|
<div id="toolbar">
|
|
<!-- Drawing tools -->
|
|
<div class="toolbar-group">
|
|
<button class="tool-btn active" data-tool="select" title="Select / Move (V)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 3l14 9-7 2-4 7z"/></svg>
|
|
</span>
|
|
<span class="label">Select</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="pen" title="Pen (P)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
|
</span>
|
|
<span class="label">Pen</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="line" title="Line (L)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/></svg>
|
|
</span>
|
|
<span class="label">Line</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="arrow" title="Arrow (A)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/><polyline points="10 5 19 5 19 14"/></svg>
|
|
</span>
|
|
<span class="label">Arrow</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="rect" title="Rectangle (R)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/></svg>
|
|
</span>
|
|
<span class="label">Rect</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="ellipse" title="Circle (C)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="10" ry="8"/></svg>
|
|
</span>
|
|
<span class="label">Circle</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="eraser" title="Eraser (E)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 20H7L3 16l9-9 8 8-4 5z"/><path d="M6 11l8 8"/></svg>
|
|
</span>
|
|
<span class="label">Eraser</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Text & Notes -->
|
|
<div class="toolbar-group">
|
|
<button class="tool-btn" data-tool="text" title="Text (T)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="20" x2="16" y2="20"/></svg>
|
|
</span>
|
|
<span class="label">Text</span>
|
|
</button>
|
|
<button class="tool-btn" data-tool="note" id="note-tool-btn" title="Sticky Note (N)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M14 3v8h8"/></svg>
|
|
</span>
|
|
<span class="label">Note</span>
|
|
</button>
|
|
<div class="note-color-picker" id="note-color-picker">
|
|
<div class="note-color-opt" data-note-color="yellow" style="background:#fff9c4;" title="Yellow"></div>
|
|
<div class="note-color-opt" data-note-color="pink" style="background:#fce4ec;" title="Pink"></div>
|
|
<div class="note-color-opt" data-note-color="blue" style="background:#e3f2fd;" title="Blue"></div>
|
|
<div class="note-color-opt" data-note-color="green" style="background:#e8f5e9;" title="Green"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Color & stroke -->
|
|
<div class="toolbar-group">
|
|
<div class="color-picker">
|
|
<div class="color-swatch active" data-color="#222222" style="background:#222222;" title="Black"></div>
|
|
<div class="color-swatch" data-color="#e53935" style="background:#e53935;" title="Red"></div>
|
|
<div class="color-swatch" data-color="#1e88e5" style="background:#1e88e5;" title="Blue"></div>
|
|
<div class="color-swatch" data-color="#43a047" style="background:#43a047;" title="Green"></div>
|
|
<div class="color-swatch" data-color="#fb8c00" style="background:#fb8c00;" title="Orange"></div>
|
|
<div class="color-swatch" data-color="#8e24aa" style="background:#8e24aa;" title="Purple"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar-group">
|
|
<button class="stroke-btn active" data-stroke="2" title="Thin">
|
|
<div class="stroke-line" style="height:2px;"></div>
|
|
<span class="label">Thin</span>
|
|
</button>
|
|
<button class="stroke-btn" data-stroke="4" title="Medium">
|
|
<div class="stroke-line" style="height:4px;"></div>
|
|
<span class="label">Med</span>
|
|
</button>
|
|
<button class="stroke-btn" data-stroke="7" title="Thick">
|
|
<div class="stroke-line" style="height:7px;"></div>
|
|
<span class="label">Thick</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Undo / Redo -->
|
|
<div class="toolbar-group">
|
|
<button class="tool-btn" id="undo-btn" title="Undo (Ctrl/Cmd+Z)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-10.36L1 10"/></svg>
|
|
</span>
|
|
<span class="label">Undo</span>
|
|
</button>
|
|
<button class="tool-btn" id="redo-btn" title="Redo (Ctrl/Cmd+Shift+Z)">
|
|
<span class="icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-5.64-10.36L23 10"/></svg>
|
|
</span>
|
|
<span class="label">Redo</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Share button -->
|
|
<button class="share-btn" id="share-btn">
|
|
<span class="icon">✉</span>
|
|
Share with Copilot
|
|
</button>
|
|
|
|
<!-- Help -->
|
|
<button class="help-btn" id="help-btn" title="Help">?</button>
|
|
</div>
|
|
|
|
<!-- ── Canvas ──────────────────────────────────────────────────── -->
|
|
<div id="canvas-container">
|
|
<canvas id="drawing-canvas"></canvas>
|
|
</div>
|
|
|
|
<!-- ── Onboarding overlay ──────────────────────────────────────── -->
|
|
<div class="overlay-backdrop" id="onboarding-overlay">
|
|
<div class="overlay-card">
|
|
<h1>Welcome to Napkin!</h1>
|
|
<p class="subtitle">Your whiteboard for brainstorming with Copilot.</p>
|
|
<div class="steps">
|
|
<div class="step">
|
|
<div class="step-num">1</div>
|
|
<div>Draw, sketch, or add sticky notes — whatever helps you think</div>
|
|
</div>
|
|
<div class="step">
|
|
<div class="step-num">2</div>
|
|
<div>When you're ready, click <strong>"Share with Copilot"</strong> (the green button)</div>
|
|
</div>
|
|
<div class="step">
|
|
<div class="step-num">3</div>
|
|
<div>Go back to your terminal and say <strong>"check the napkin"</strong></div>
|
|
</div>
|
|
<div class="step">
|
|
<div class="step-num">4</div>
|
|
<div>Copilot will look at your whiteboard and respond</div>
|
|
</div>
|
|
</div>
|
|
<p style="font-size:14px;color:#888;margin-bottom:20px;">That's it. Let's go!</p>
|
|
<button class="cta-btn" id="onboarding-dismiss">Got it — start drawing</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Share confirmation overlay ──────────────────────────────── -->
|
|
<div class="overlay-backdrop hidden" id="share-overlay">
|
|
<div class="overlay-card">
|
|
<div class="confirm-icon">✔️</div>
|
|
<h1>Shared with Copilot!</h1>
|
|
<div class="confirm-detail">
|
|
💾 A screenshot was saved (check your Downloads or Desktop).<br>
|
|
📋 The text content was copied to your clipboard.<br><br>
|
|
Go back to Copilot CLI and say:<br>
|
|
<span class="clipboard-hint">"check the napkin"</span>
|
|
</div>
|
|
<button class="cta-btn" id="share-overlay-close">Got it</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Keyboard shortcuts panel ────────────────────────────────── -->
|
|
<div class="shortcuts-panel" id="shortcuts-panel">
|
|
<button class="close-shortcuts" id="close-shortcuts">×</button>
|
|
<h3>Keyboard Shortcuts</h3>
|
|
<div class="shortcut-row"><span>Select / Move</span><kbd>V</kbd></div>
|
|
<div class="shortcut-row"><span>Pen</span><kbd>P</kbd></div>
|
|
<div class="shortcut-row"><span>Rectangle</span><kbd>R</kbd></div>
|
|
<div class="shortcut-row"><span>Circle</span><kbd>C</kbd></div>
|
|
<div class="shortcut-row"><span>Arrow</span><kbd>A</kbd></div>
|
|
<div class="shortcut-row"><span>Line</span><kbd>L</kbd></div>
|
|
<div class="shortcut-row"><span>Text</span><kbd>T</kbd></div>
|
|
<div class="shortcut-row"><span>Sticky Note</span><kbd>N</kbd></div>
|
|
<div class="shortcut-row"><span>Eraser</span><kbd>E</kbd></div>
|
|
<div class="shortcut-row"><span>Undo</span><kbd>Ctrl/Cmd+Z</kbd></div>
|
|
<div class="shortcut-row"><span>Redo</span><kbd>Ctrl/Cmd+Shift+Z</kbd></div>
|
|
<div class="shortcut-row"><span>Pan canvas</span><kbd>Space+Drag</kbd></div>
|
|
</div>
|
|
|
|
<!-- ── Zoom indicator ──────────────────────────────────────────── -->
|
|
<div class="zoom-indicator">
|
|
<button id="zoom-out-btn" title="Zoom out">−</button>
|
|
<span id="zoom-level">100%</span>
|
|
<button id="zoom-in-btn" title="Zoom in">+</button>
|
|
<button id="fit-btn" title="Fit to content" style="font-size:11px;width:auto;padding:0 8px;">Fit</button>
|
|
</div>
|
|
|
|
<!-- ── Toast ───────────────────────────────────────────────────── -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// NAPKIN — Self-contained whiteboard for Copilot collaboration
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── DOM references ───────────────────────────────────────────────
|
|
const container = document.getElementById('canvas-container');
|
|
const canvas = document.getElementById('drawing-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const toolbar = document.getElementById('toolbar');
|
|
const toastEl = document.getElementById('toast');
|
|
const onboarding = document.getElementById('onboarding-overlay');
|
|
const shareOverlay = document.getElementById('share-overlay');
|
|
const noteColorPicker = document.getElementById('note-color-picker');
|
|
|
|
// ── State ────────────────────────────────────────────────────────
|
|
const CANVAS_W = 3840;
|
|
const CANVAS_H = 2160;
|
|
|
|
let currentTool = 'select';
|
|
let currentColor = '#222222';
|
|
let currentStroke = 2;
|
|
let noteColor = 'yellow';
|
|
|
|
// View transform
|
|
let viewX = 0, viewY = 0, viewScale = 1;
|
|
|
|
// Drawing state
|
|
let isDrawing = false;
|
|
let isPanning = false;
|
|
let spaceHeld = false;
|
|
let eraserDidErase = false;
|
|
let panStartX = 0, panStartY = 0;
|
|
let panViewStartX = 0, panViewStartY = 0;
|
|
|
|
// Objects
|
|
let drawingObjects = []; // { type, points?, x?, y?, ... }
|
|
let stickyNotes = []; // { id, text, x, y, w, h, color }
|
|
let textLabels = []; // { id, text, x, y, fontSize }
|
|
|
|
// Current in-progress drawing
|
|
let currentPath = null;
|
|
|
|
// Undo/redo stacks
|
|
let undoStack = [];
|
|
let redoStack = [];
|
|
|
|
// Unique ID counter
|
|
let idCounter = Date.now();
|
|
function uid() { return 'n' + (idCounter++); }
|
|
|
|
// ── Utility ──────────────────────────────────────────────────────
|
|
function screenToCanvas(sx, sy) {
|
|
const rect = container.getBoundingClientRect();
|
|
return {
|
|
x: (sx - rect.left - viewX) / viewScale,
|
|
y: (sy - rect.top - viewY) / viewScale
|
|
};
|
|
}
|
|
|
|
function showToast(msg, duration) {
|
|
toastEl.textContent = msg;
|
|
toastEl.classList.add('show');
|
|
clearTimeout(showToast._t);
|
|
showToast._t = setTimeout(() => toastEl.classList.remove('show'), duration || 2500);
|
|
}
|
|
|
|
// ── Onboarding ───────────────────────────────────────────────────
|
|
function initOnboarding() {
|
|
if (localStorage.getItem('napkin_onboarded')) {
|
|
onboarding.classList.add('hidden');
|
|
}
|
|
document.getElementById('onboarding-dismiss').addEventListener('click', () => {
|
|
onboarding.classList.add('hidden');
|
|
localStorage.setItem('napkin_onboarded', '1');
|
|
});
|
|
document.getElementById('help-btn').addEventListener('click', () => {
|
|
onboarding.classList.remove('hidden');
|
|
});
|
|
}
|
|
|
|
// ── Canvas setup ─────────────────────────────────────────────────
|
|
function initCanvas() {
|
|
canvas.width = CANVAS_W;
|
|
canvas.height = CANVAS_H;
|
|
centerView();
|
|
render();
|
|
}
|
|
|
|
function centerView() {
|
|
const cw = container.clientWidth;
|
|
const ch = container.clientHeight;
|
|
viewScale = Math.min(cw / CANVAS_W, ch / CANVAS_H, 1) * 0.9;
|
|
viewX = (cw - CANVAS_W * viewScale) / 2;
|
|
viewY = (ch - CANVAS_H * viewScale) / 2;
|
|
updateCanvasTransform();
|
|
}
|
|
|
|
function updateCanvasTransform() {
|
|
canvas.style.left = viewX + 'px';
|
|
canvas.style.top = viewY + 'px';
|
|
canvas.style.width = (CANVAS_W * viewScale) + 'px';
|
|
canvas.style.height = (CANVAS_H * viewScale) + 'px';
|
|
document.getElementById('zoom-level').textContent = Math.round(viewScale * 100) + '%';
|
|
|
|
// Reposition sticky notes and text labels
|
|
repositionOverlays();
|
|
}
|
|
|
|
function repositionOverlays() {
|
|
document.querySelectorAll('.sticky-note').forEach(el => {
|
|
const note = stickyNotes.find(n => n.id === el.dataset.noteId);
|
|
if (!note) return;
|
|
el.style.left = (viewX + note.x * viewScale) + 'px';
|
|
el.style.top = (viewY + note.y * viewScale) + 'px';
|
|
el.style.width = (note.w * viewScale) + 'px';
|
|
el.style.height = (note.h * viewScale) + 'px';
|
|
el.style.fontSize = (14 * viewScale) + 'px';
|
|
});
|
|
document.querySelectorAll('.canvas-text-label').forEach(el => {
|
|
const lbl = textLabels.find(l => l.id === el.dataset.labelId);
|
|
if (!lbl) return;
|
|
el.style.left = (viewX + lbl.x * viewScale) + 'px';
|
|
el.style.top = (viewY + lbl.y * viewScale) + 'px';
|
|
el.style.fontSize = (lbl.fontSize * viewScale) + 'px';
|
|
});
|
|
}
|
|
|
|
// ── Render canvas objects ────────────────────────────────────────
|
|
function render() {
|
|
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
// Draw grid (very subtle)
|
|
ctx.strokeStyle = '#f0f0f0';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x < CANVAS_W; x += 40) {
|
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke();
|
|
}
|
|
for (let y = 0; y < CANVAS_H; y += 40) {
|
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke();
|
|
}
|
|
|
|
// Draw all objects
|
|
drawingObjects.forEach(obj => drawObject(ctx, obj));
|
|
|
|
// Draw current in-progress path
|
|
if (currentPath) {
|
|
drawObject(ctx, currentPath);
|
|
}
|
|
}
|
|
|
|
function drawObject(c, obj) {
|
|
c.lineCap = 'round';
|
|
c.lineJoin = 'round';
|
|
|
|
switch (obj.type) {
|
|
case 'pen': {
|
|
if (obj.points.length < 2) return;
|
|
c.strokeStyle = obj.color;
|
|
c.lineWidth = obj.stroke;
|
|
c.beginPath();
|
|
c.moveTo(obj.points[0].x, obj.points[0].y);
|
|
for (let i = 1; i < obj.points.length; i++) {
|
|
c.lineTo(obj.points[i].x, obj.points[i].y);
|
|
}
|
|
c.stroke();
|
|
break;
|
|
}
|
|
case 'line': {
|
|
c.strokeStyle = obj.color;
|
|
c.lineWidth = obj.stroke;
|
|
c.beginPath();
|
|
c.moveTo(obj.x1, obj.y1);
|
|
c.lineTo(obj.x2, obj.y2);
|
|
c.stroke();
|
|
break;
|
|
}
|
|
case 'arrow': {
|
|
c.strokeStyle = obj.color;
|
|
c.lineWidth = obj.stroke;
|
|
c.fillStyle = obj.color;
|
|
c.beginPath();
|
|
c.moveTo(obj.x1, obj.y1);
|
|
c.lineTo(obj.x2, obj.y2);
|
|
c.stroke();
|
|
// Arrowhead
|
|
const angle = Math.atan2(obj.y2 - obj.y1, obj.x2 - obj.x1);
|
|
const headLen = 12 + obj.stroke * 2;
|
|
c.beginPath();
|
|
c.moveTo(obj.x2, obj.y2);
|
|
c.lineTo(obj.x2 - headLen * Math.cos(angle - 0.4), obj.y2 - headLen * Math.sin(angle - 0.4));
|
|
c.lineTo(obj.x2 - headLen * Math.cos(angle + 0.4), obj.y2 - headLen * Math.sin(angle + 0.4));
|
|
c.closePath();
|
|
c.fill();
|
|
break;
|
|
}
|
|
case 'rect': {
|
|
c.strokeStyle = obj.color;
|
|
c.lineWidth = obj.stroke;
|
|
c.beginPath();
|
|
c.rect(obj.x, obj.y, obj.w, obj.h);
|
|
c.stroke();
|
|
break;
|
|
}
|
|
case 'ellipse': {
|
|
c.strokeStyle = obj.color;
|
|
c.lineWidth = obj.stroke;
|
|
c.beginPath();
|
|
const cx = obj.x + obj.w / 2;
|
|
const cy = obj.y + obj.h / 2;
|
|
c.ellipse(cx, cy, Math.abs(obj.w / 2), Math.abs(obj.h / 2), 0, 0, Math.PI * 2);
|
|
c.stroke();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Shape recognition ────────────────────────────────────────────
|
|
function recognizeShape(points) {
|
|
if (points.length < 10) return null;
|
|
|
|
const first = points[0];
|
|
const last = points[points.length - 1];
|
|
const dist = Math.hypot(last.x - first.x, last.y - first.y);
|
|
|
|
// Bounding box
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
points.forEach(p => {
|
|
if (p.x < minX) minX = p.x;
|
|
if (p.y < minY) minY = p.y;
|
|
if (p.x > maxX) maxX = p.x;
|
|
if (p.y > maxY) maxY = p.y;
|
|
});
|
|
const bw = maxX - minX;
|
|
const bh = maxY - minY;
|
|
const diagonal = Math.hypot(bw, bh);
|
|
|
|
// Check if path closes (endpoints near each other relative to size)
|
|
if (dist > diagonal * 0.25) return null;
|
|
|
|
// Compute total path length
|
|
let pathLen = 0;
|
|
for (let i = 1; i < points.length; i++) {
|
|
pathLen += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
|
}
|
|
|
|
// Skip tiny shapes
|
|
if (bw < 20 || bh < 20) return null;
|
|
|
|
// Check rectangularity by analyzing corner angles
|
|
const cx = (minX + maxX) / 2;
|
|
const cy = (minY + maxY) / 2;
|
|
|
|
// Measure how well points fit an ellipse vs a rectangle
|
|
let ellipseError = 0;
|
|
let rectError = 0;
|
|
const rx = bw / 2;
|
|
const ry = bh / 2;
|
|
|
|
points.forEach(p => {
|
|
// Ellipse error: distance from ellipse boundary
|
|
const dx = (p.x - cx) / rx;
|
|
const dy = (p.y - cy) / ry;
|
|
const r = Math.sqrt(dx * dx + dy * dy);
|
|
ellipseError += Math.abs(r - 1);
|
|
|
|
// Rectangle error: distance from nearest rectangle edge
|
|
const distToLeft = Math.abs(p.x - minX);
|
|
const distToRight = Math.abs(p.x - maxX);
|
|
const distToTop = Math.abs(p.y - minY);
|
|
const distToBottom = Math.abs(p.y - maxY);
|
|
rectError += Math.min(distToLeft, distToRight, distToTop, distToBottom);
|
|
});
|
|
|
|
ellipseError /= points.length;
|
|
rectError /= points.length;
|
|
|
|
// Normalize errors
|
|
const normEllipse = ellipseError;
|
|
const normRect = rectError / Math.max(bw, bh) * 4;
|
|
|
|
if (normEllipse < 0.35 && normEllipse < normRect) {
|
|
return { type: 'ellipse', x: minX, y: minY, w: bw, h: bh };
|
|
}
|
|
|
|
if (normRect < 0.25) {
|
|
return { type: 'rect', x: minX, y: minY, w: bw, h: bh };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── roundRect fallback for older browsers ──────────────────────
|
|
function safeRoundRect(ctx, x, y, w, h, radii) {
|
|
if (typeof ctx.roundRect === 'function') {
|
|
ctx.roundRect(x, y, w, h, radii);
|
|
return;
|
|
}
|
|
const r = Array.isArray(radii) ? radii : [radii, radii, radii, radii];
|
|
const [tl, tr, br, bl] = r.length === 4 ? r : r.length === 2 ? [r[0], r[1], r[0], r[1]] : [r[0], r[0], r[0], r[0]];
|
|
ctx.moveTo(x + tl, y);
|
|
ctx.lineTo(x + w - tr, y);
|
|
ctx.arcTo(x + w, y, x + w, y + tr, tr);
|
|
ctx.lineTo(x + w, y + h - br);
|
|
ctx.arcTo(x + w, y + h, x + w - br, y + h, br);
|
|
ctx.lineTo(x + bl, y + h);
|
|
ctx.arcTo(x, y + h, x, y + h - bl, bl);
|
|
ctx.lineTo(x, y + tl);
|
|
ctx.arcTo(x, y, x + tl, y, tl);
|
|
ctx.closePath();
|
|
}
|
|
|
|
// ── Eraser ───────────────────────────────────────────────────────
|
|
function eraseAt(cx, cy, radius) {
|
|
const r2 = radius * radius;
|
|
const before = drawingObjects.length;
|
|
|
|
drawingObjects = drawingObjects.filter(obj => {
|
|
switch (obj.type) {
|
|
case 'pen':
|
|
return !obj.points.some(p => (p.x - cx) ** 2 + (p.y - cy) ** 2 < r2);
|
|
case 'line':
|
|
case 'arrow':
|
|
return distToSegment(cx, cy, obj.x1, obj.y1, obj.x2, obj.y2) > radius;
|
|
case 'rect':
|
|
return !(cx > obj.x - radius && cx < obj.x + obj.w + radius &&
|
|
cy > obj.y - radius && cy < obj.y + obj.h + radius);
|
|
case 'ellipse': {
|
|
const ecx = obj.x + obj.w / 2;
|
|
const ecy = obj.y + obj.h / 2;
|
|
const dx = (cx - ecx) / (Math.abs(obj.w) / 2 + radius);
|
|
const dy = (cy - ecy) / (Math.abs(obj.h) / 2 + radius);
|
|
return (dx * dx + dy * dy) > 1.0;
|
|
}
|
|
default: return true;
|
|
}
|
|
});
|
|
|
|
if (drawingObjects.length !== before) {
|
|
eraserDidErase = true;
|
|
render();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function distToSegment(px, py, x1, y1, x2, y2) {
|
|
const dx = x2 - x1, dy = y2 - y1;
|
|
const lenSq = dx * dx + dy * dy;
|
|
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
|
|
let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
|
|
t = Math.max(0, Math.min(1, t));
|
|
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
|
|
}
|
|
|
|
// ── Undo / Redo ──────────────────────────────────────────────────
|
|
function saveState() {
|
|
undoStack.push({
|
|
objects: JSON.parse(JSON.stringify(drawingObjects)),
|
|
notes: JSON.parse(JSON.stringify(stickyNotes)),
|
|
labels: JSON.parse(JSON.stringify(textLabels))
|
|
});
|
|
if (undoStack.length > 60) undoStack.shift();
|
|
redoStack = [];
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
function undo() {
|
|
if (undoStack.length === 0) return;
|
|
redoStack.push({
|
|
objects: JSON.parse(JSON.stringify(drawingObjects)),
|
|
notes: JSON.parse(JSON.stringify(stickyNotes)),
|
|
labels: JSON.parse(JSON.stringify(textLabels))
|
|
});
|
|
const state = undoStack.pop();
|
|
drawingObjects = state.objects;
|
|
stickyNotes = state.notes;
|
|
textLabels = state.labels;
|
|
rebuildOverlays();
|
|
render();
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
function redo() {
|
|
if (redoStack.length === 0) return;
|
|
undoStack.push({
|
|
objects: JSON.parse(JSON.stringify(drawingObjects)),
|
|
notes: JSON.parse(JSON.stringify(stickyNotes)),
|
|
labels: JSON.parse(JSON.stringify(textLabels))
|
|
});
|
|
const state = redoStack.pop();
|
|
drawingObjects = state.objects;
|
|
stickyNotes = state.notes;
|
|
textLabels = state.labels;
|
|
rebuildOverlays();
|
|
render();
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
document.getElementById('undo-btn').addEventListener('click', undo);
|
|
document.getElementById('redo-btn').addEventListener('click', redo);
|
|
|
|
// ── Tool selection ───────────────────────────────────────────────
|
|
function setTool(tool) {
|
|
currentTool = tool;
|
|
document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.tool === tool);
|
|
});
|
|
container.style.cursor = tool === 'select' ? 'default' :
|
|
tool === 'eraser' ? 'cell' : 'crosshair';
|
|
noteColorPicker.classList.remove('show');
|
|
}
|
|
|
|
toolbar.addEventListener('click', e => {
|
|
const btn = e.target.closest('.tool-btn[data-tool]');
|
|
if (!btn) return;
|
|
const tool = btn.dataset.tool;
|
|
|
|
if (tool === 'note') {
|
|
noteColorPicker.classList.toggle('show');
|
|
const rect = btn.getBoundingClientRect();
|
|
noteColorPicker.style.left = rect.left + 'px';
|
|
} else {
|
|
setTool(tool);
|
|
}
|
|
});
|
|
|
|
// Note color picker
|
|
noteColorPicker.addEventListener('click', e => {
|
|
const opt = e.target.closest('.note-color-opt');
|
|
if (!opt) return;
|
|
noteColor = opt.dataset.noteColor;
|
|
noteColorPicker.classList.remove('show');
|
|
setTool('note');
|
|
});
|
|
|
|
// Color swatches
|
|
document.querySelectorAll('.color-swatch').forEach(s => {
|
|
s.addEventListener('click', () => {
|
|
document.querySelectorAll('.color-swatch').forEach(el => el.classList.remove('active'));
|
|
s.classList.add('active');
|
|
currentColor = s.dataset.color;
|
|
});
|
|
});
|
|
|
|
// Stroke buttons
|
|
document.querySelectorAll('.stroke-btn').forEach(s => {
|
|
s.addEventListener('click', () => {
|
|
document.querySelectorAll('.stroke-btn').forEach(el => el.classList.remove('active'));
|
|
s.classList.add('active');
|
|
currentStroke = parseInt(s.dataset.stroke, 10);
|
|
});
|
|
});
|
|
|
|
// ── Mouse / pointer events on canvas ─────────────────────────────
|
|
let drawStartX, drawStartY;
|
|
|
|
container.addEventListener('pointerdown', e => {
|
|
if (e.target.closest('#toolbar') || e.target.closest('.sticky-note') ||
|
|
e.target.closest('.canvas-text-label') || e.target.closest('.overlay-backdrop') ||
|
|
e.target.closest('.shortcuts-panel') || e.target.closest('.zoom-indicator')) return;
|
|
|
|
const pt = screenToCanvas(e.clientX, e.clientY);
|
|
|
|
// Pan with space or middle button
|
|
if (spaceHeld || e.button === 1) {
|
|
isPanning = true;
|
|
panStartX = e.clientX;
|
|
panStartY = e.clientY;
|
|
panViewStartX = viewX;
|
|
panViewStartY = viewY;
|
|
container.classList.add('panning');
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (e.button !== 0) return;
|
|
|
|
switch (currentTool) {
|
|
case 'pen':
|
|
case 'eraser': {
|
|
isDrawing = true;
|
|
if (currentTool === 'pen') {
|
|
currentPath = { type: 'pen', points: [pt], color: currentColor, stroke: currentStroke };
|
|
} else {
|
|
eraserDidErase = false;
|
|
const redoStackBeforeEraser = redoStack.slice();
|
|
saveState();
|
|
eraseAt(pt.x, pt.y, 16);
|
|
if (!eraserDidErase) {
|
|
redoStack = redoStackBeforeEraser;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'line':
|
|
case 'arrow':
|
|
case 'rect':
|
|
case 'ellipse': {
|
|
isDrawing = true;
|
|
drawStartX = pt.x;
|
|
drawStartY = pt.y;
|
|
if (currentTool === 'line' || currentTool === 'arrow') {
|
|
currentPath = { type: currentTool, x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y, color: currentColor, stroke: currentStroke };
|
|
} else {
|
|
currentPath = { type: currentTool, x: pt.x, y: pt.y, w: 0, h: 0, color: currentColor, stroke: currentStroke };
|
|
}
|
|
break;
|
|
}
|
|
case 'text': {
|
|
createTextLabel(pt.x, pt.y);
|
|
break;
|
|
}
|
|
case 'note': {
|
|
createStickyNote(pt.x, pt.y);
|
|
setTool('select');
|
|
break;
|
|
}
|
|
case 'select': {
|
|
// In select mode, clicking empty canvas does nothing special
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
container.addEventListener('pointermove', e => {
|
|
if (isPanning) {
|
|
viewX = panViewStartX + (e.clientX - panStartX);
|
|
viewY = panViewStartY + (e.clientY - panStartY);
|
|
updateCanvasTransform();
|
|
return;
|
|
}
|
|
|
|
if (!isDrawing) return;
|
|
const pt = screenToCanvas(e.clientX, e.clientY);
|
|
|
|
switch (currentTool) {
|
|
case 'pen': {
|
|
if (currentPath) {
|
|
currentPath.points.push(pt);
|
|
render();
|
|
}
|
|
break;
|
|
}
|
|
case 'eraser': {
|
|
eraseAt(pt.x, pt.y, 16);
|
|
break;
|
|
}
|
|
case 'line':
|
|
case 'arrow': {
|
|
if (currentPath) {
|
|
currentPath.x2 = pt.x;
|
|
currentPath.y2 = pt.y;
|
|
render();
|
|
}
|
|
break;
|
|
}
|
|
case 'rect':
|
|
case 'ellipse': {
|
|
if (currentPath) {
|
|
currentPath.x = Math.min(drawStartX, pt.x);
|
|
currentPath.y = Math.min(drawStartY, pt.y);
|
|
currentPath.w = Math.abs(pt.x - drawStartX);
|
|
currentPath.h = Math.abs(pt.y - drawStartY);
|
|
render();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
function finishDrawing() {
|
|
if (isPanning) {
|
|
isPanning = false;
|
|
container.classList.remove('panning');
|
|
return;
|
|
}
|
|
|
|
if (!isDrawing) return;
|
|
isDrawing = false;
|
|
|
|
if (currentTool === 'eraser') {
|
|
if (!eraserDidErase) {
|
|
// Nothing was erased — pop the pre-erase state we saved on pointerdown
|
|
undoStack.pop();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!currentPath) return;
|
|
|
|
// Shape recognition for pen tool
|
|
if (currentPath.type === 'pen') {
|
|
const recognized = recognizeShape(currentPath.points);
|
|
if (recognized) {
|
|
currentPath = {
|
|
...recognized,
|
|
color: currentPath.color,
|
|
stroke: currentPath.stroke
|
|
};
|
|
}
|
|
}
|
|
|
|
// Don't save degenerate shapes
|
|
if (currentPath.type === 'pen' && currentPath.points.length < 2) {
|
|
currentPath = null;
|
|
return;
|
|
}
|
|
if ((currentPath.type === 'rect' || currentPath.type === 'ellipse') &&
|
|
(Math.abs(currentPath.w) < 3 && Math.abs(currentPath.h) < 3)) {
|
|
currentPath = null;
|
|
render();
|
|
return;
|
|
}
|
|
if ((currentPath.type === 'line' || currentPath.type === 'arrow') &&
|
|
Math.hypot(currentPath.x2 - currentPath.x1, currentPath.y2 - currentPath.y1) < 3) {
|
|
currentPath = null;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
saveState();
|
|
drawingObjects.push(currentPath);
|
|
currentPath = null;
|
|
render();
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
container.addEventListener('pointerup', finishDrawing);
|
|
container.addEventListener('pointerleave', finishDrawing);
|
|
|
|
// ── Zoom ─────────────────────────────────────────────────────────
|
|
container.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
|
zoomAt(e.clientX, e.clientY, delta);
|
|
}, { passive: false });
|
|
|
|
function zoomAt(sx, sy, factor) {
|
|
const rect = container.getBoundingClientRect();
|
|
const mx = sx - rect.left;
|
|
const my = sy - rect.top;
|
|
|
|
const newScale = Math.min(Math.max(viewScale * factor, 0.1), 5);
|
|
const scaleRatio = newScale / viewScale;
|
|
|
|
viewX = mx - (mx - viewX) * scaleRatio;
|
|
viewY = my - (my - viewY) * scaleRatio;
|
|
viewScale = newScale;
|
|
updateCanvasTransform();
|
|
}
|
|
|
|
document.getElementById('zoom-in-btn').addEventListener('click', () => {
|
|
const rect = container.getBoundingClientRect();
|
|
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.2);
|
|
});
|
|
document.getElementById('zoom-out-btn').addEventListener('click', () => {
|
|
const rect = container.getBoundingClientRect();
|
|
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 0.8);
|
|
});
|
|
document.getElementById('fit-btn').addEventListener('click', fitToContent);
|
|
|
|
function fitToContent() {
|
|
// Find bounding box of all content
|
|
let minX = CANVAS_W, minY = CANVAS_H, maxX = 0, maxY = 0;
|
|
let hasContent = false;
|
|
|
|
drawingObjects.forEach(obj => {
|
|
hasContent = true;
|
|
switch (obj.type) {
|
|
case 'pen':
|
|
obj.points.forEach(p => {
|
|
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
|
maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
|
|
});
|
|
break;
|
|
case 'line': case 'arrow':
|
|
minX = Math.min(minX, obj.x1, obj.x2); minY = Math.min(minY, obj.y1, obj.y2);
|
|
maxX = Math.max(maxX, obj.x1, obj.x2); maxY = Math.max(maxY, obj.y1, obj.y2);
|
|
break;
|
|
case 'rect': case 'ellipse':
|
|
minX = Math.min(minX, obj.x); minY = Math.min(minY, obj.y);
|
|
maxX = Math.max(maxX, obj.x + obj.w); maxY = Math.max(maxY, obj.y + obj.h);
|
|
break;
|
|
}
|
|
});
|
|
stickyNotes.forEach(n => {
|
|
hasContent = true;
|
|
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
|
|
maxX = Math.max(maxX, n.x + n.w); maxY = Math.max(maxY, n.y + n.h);
|
|
});
|
|
textLabels.forEach(l => {
|
|
hasContent = true;
|
|
minX = Math.min(minX, l.x); minY = Math.min(minY, l.y);
|
|
maxX = Math.max(maxX, l.x + 200); maxY = Math.max(maxY, l.y + 30);
|
|
});
|
|
|
|
if (!hasContent) {
|
|
centerView();
|
|
return;
|
|
}
|
|
|
|
const pad = 80;
|
|
minX -= pad; minY -= pad; maxX += pad; maxY += pad;
|
|
const cw = container.clientWidth;
|
|
const ch = container.clientHeight;
|
|
viewScale = Math.min(cw / (maxX - minX), ch / (maxY - minY), 2);
|
|
viewX = (cw - (maxX - minX) * viewScale) / 2 - minX * viewScale;
|
|
viewY = (ch - (maxY - minY) * viewScale) / 2 - minY * viewScale;
|
|
updateCanvasTransform();
|
|
}
|
|
|
|
// ── Keyboard ─────────────────────────────────────────────────────
|
|
document.addEventListener('keydown', e => {
|
|
// Don't capture when typing in inputs
|
|
if (e.target.isContentEditable || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
if (e.key === 'Escape') e.target.blur();
|
|
return;
|
|
}
|
|
|
|
if (e.key === ' ') {
|
|
e.preventDefault();
|
|
spaceHeld = true;
|
|
container.classList.add('panning');
|
|
}
|
|
|
|
// Ctrl/Cmd shortcuts
|
|
if (e.metaKey || e.ctrlKey) {
|
|
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
|
|
if (e.key === 'z' && e.shiftKey) { e.preventDefault(); redo(); }
|
|
if (e.key === 'Z') { e.preventDefault(); redo(); }
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
// No specific selection handling in v1 beyond sticky notes
|
|
return;
|
|
}
|
|
|
|
const keyMap = { v: 'select', p: 'pen', r: 'rect', c: 'ellipse', a: 'arrow', l: 'line', t: 'text', n: 'note', e: 'eraser' };
|
|
if (keyMap[e.key]) {
|
|
if (e.key === 'n') {
|
|
noteColorPicker.classList.toggle('show');
|
|
const btn = document.getElementById('note-tool-btn');
|
|
const rect = btn.getBoundingClientRect();
|
|
noteColorPicker.style.left = rect.left + 'px';
|
|
} else {
|
|
setTool(keyMap[e.key]);
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keyup', e => {
|
|
if (e.key === ' ') {
|
|
spaceHeld = false;
|
|
if (!isPanning) container.classList.remove('panning');
|
|
}
|
|
});
|
|
|
|
// Shortcuts panel
|
|
document.getElementById('close-shortcuts').addEventListener('click', () => {
|
|
document.getElementById('shortcuts-panel').classList.remove('show');
|
|
});
|
|
|
|
// Show shortcuts with ? key when not typing
|
|
document.addEventListener('keydown', e => {
|
|
if (e.target.isContentEditable || e.target.tagName === 'INPUT') return;
|
|
if (e.key === '?') {
|
|
document.getElementById('shortcuts-panel').classList.toggle('show');
|
|
}
|
|
});
|
|
|
|
// ── Sticky notes ─────────────────────────────────────────────────
|
|
function createStickyNote(x, y) {
|
|
const note = {
|
|
id: uid(),
|
|
text: '',
|
|
x: x,
|
|
y: y,
|
|
w: 200,
|
|
h: 160,
|
|
color: noteColor
|
|
};
|
|
saveState();
|
|
stickyNotes.push(note);
|
|
renderStickyNote(note, true);
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
function renderStickyNote(note, focusAfter) {
|
|
const el = document.createElement('div');
|
|
el.className = 'sticky-note sticky-' + note.color;
|
|
el.dataset.noteId = note.id;
|
|
el.style.left = (viewX + note.x * viewScale) + 'px';
|
|
el.style.top = (viewY + note.y * viewScale) + 'px';
|
|
el.style.width = (note.w * viewScale) + 'px';
|
|
el.style.height = (note.h * viewScale) + 'px';
|
|
el.style.fontSize = (14 * viewScale) + 'px';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'note-header';
|
|
|
|
const del = document.createElement('button');
|
|
del.className = 'note-delete';
|
|
del.textContent = '\u00d7';
|
|
del.addEventListener('click', () => {
|
|
saveState();
|
|
stickyNotes = stickyNotes.filter(n => n.id !== note.id);
|
|
el.remove();
|
|
scheduleAutoSave();
|
|
});
|
|
header.appendChild(del);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'note-body';
|
|
body.contentEditable = 'true';
|
|
body.textContent = note.text;
|
|
body.addEventListener('input', () => {
|
|
note.text = body.textContent;
|
|
scheduleAutoSave();
|
|
});
|
|
body.addEventListener('blur', () => {
|
|
note.text = body.textContent;
|
|
scheduleAutoSave();
|
|
});
|
|
|
|
const resize = document.createElement('div');
|
|
resize.className = 'note-resize';
|
|
|
|
el.appendChild(header);
|
|
el.appendChild(body);
|
|
el.appendChild(resize);
|
|
container.appendChild(el);
|
|
|
|
// Drag header to move
|
|
let dragOffX, dragOffY, isDragging = false;
|
|
header.addEventListener('pointerdown', e => {
|
|
isDragging = true;
|
|
const rect = el.getBoundingClientRect();
|
|
dragOffX = e.clientX - rect.left;
|
|
dragOffY = e.clientY - rect.top;
|
|
e.preventDefault();
|
|
header.setPointerCapture(e.pointerId);
|
|
});
|
|
header.addEventListener('pointermove', e => {
|
|
if (!isDragging) return;
|
|
const cRect = container.getBoundingClientRect();
|
|
const newLeft = e.clientX - cRect.left - dragOffX;
|
|
const newTop = e.clientY - cRect.top - dragOffY;
|
|
note.x = (newLeft - viewX) / viewScale;
|
|
note.y = (newTop - viewY) / viewScale;
|
|
el.style.left = newLeft + 'px';
|
|
el.style.top = newTop + 'px';
|
|
});
|
|
header.addEventListener('pointerup', () => {
|
|
if (isDragging) scheduleAutoSave();
|
|
isDragging = false;
|
|
});
|
|
|
|
// Resize handle
|
|
let isResizing = false, resizeStartW, resizeStartH, resizeStartMx, resizeStartMy;
|
|
resize.addEventListener('pointerdown', e => {
|
|
isResizing = true;
|
|
resizeStartW = note.w;
|
|
resizeStartH = note.h;
|
|
resizeStartMx = e.clientX;
|
|
resizeStartMy = e.clientY;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
resize.setPointerCapture(e.pointerId);
|
|
});
|
|
resize.addEventListener('pointermove', e => {
|
|
if (!isResizing) return;
|
|
const dw = (e.clientX - resizeStartMx) / viewScale;
|
|
const dh = (e.clientY - resizeStartMy) / viewScale;
|
|
note.w = Math.max(120, resizeStartW + dw);
|
|
note.h = Math.max(80, resizeStartH + dh);
|
|
el.style.width = (note.w * viewScale) + 'px';
|
|
el.style.height = (note.h * viewScale) + 'px';
|
|
});
|
|
resize.addEventListener('pointerup', () => {
|
|
if (isResizing) scheduleAutoSave();
|
|
isResizing = false;
|
|
});
|
|
|
|
if (focusAfter) {
|
|
setTimeout(() => body.focus(), 50);
|
|
}
|
|
}
|
|
|
|
// ── Text labels ──────────────────────────────────────────────────
|
|
function createTextLabel(x, y) {
|
|
const lbl = {
|
|
id: uid(),
|
|
text: '',
|
|
x: x,
|
|
y: y,
|
|
fontSize: 16
|
|
};
|
|
saveState();
|
|
textLabels.push(lbl);
|
|
renderTextLabel(lbl, true);
|
|
setTool('select');
|
|
scheduleAutoSave();
|
|
}
|
|
|
|
function renderTextLabel(lbl, focusAfter) {
|
|
const el = document.createElement('div');
|
|
el.className = 'canvas-text-label';
|
|
el.dataset.labelId = lbl.id;
|
|
el.contentEditable = 'true';
|
|
el.style.left = (viewX + lbl.x * viewScale) + 'px';
|
|
el.style.top = (viewY + lbl.y * viewScale) + 'px';
|
|
el.style.fontSize = (lbl.fontSize * viewScale) + 'px';
|
|
el.textContent = lbl.text;
|
|
|
|
el.addEventListener('input', () => {
|
|
lbl.text = el.textContent;
|
|
scheduleAutoSave();
|
|
});
|
|
el.addEventListener('blur', () => {
|
|
lbl.text = el.textContent;
|
|
if (!lbl.text.trim()) {
|
|
textLabels = textLabels.filter(l => l.id !== lbl.id);
|
|
el.remove();
|
|
}
|
|
scheduleAutoSave();
|
|
});
|
|
|
|
container.appendChild(el);
|
|
if (focusAfter) {
|
|
setTimeout(() => el.focus(), 50);
|
|
}
|
|
}
|
|
|
|
// ── Rebuild overlays from data (for undo/redo) ───────────────────
|
|
function rebuildOverlays() {
|
|
document.querySelectorAll('.sticky-note').forEach(el => el.remove());
|
|
document.querySelectorAll('.canvas-text-label').forEach(el => el.remove());
|
|
stickyNotes.forEach(n => renderStickyNote(n, false));
|
|
textLabels.forEach(l => renderTextLabel(l, false));
|
|
}
|
|
|
|
// ── Auto-save to localStorage ────────────────────────────────────
|
|
let autoSaveTimer = null;
|
|
|
|
function scheduleAutoSave() {
|
|
clearTimeout(autoSaveTimer);
|
|
autoSaveTimer = setTimeout(autoSave, 2000);
|
|
}
|
|
|
|
function autoSave() {
|
|
try {
|
|
const state = {
|
|
objects: drawingObjects,
|
|
notes: stickyNotes,
|
|
labels: textLabels
|
|
};
|
|
localStorage.setItem('napkin_state', JSON.stringify(state));
|
|
} catch (e) {
|
|
// localStorage might be full; silently ignore
|
|
}
|
|
}
|
|
|
|
// Periodic save every 10 seconds
|
|
setInterval(autoSave, 10000);
|
|
|
|
function loadState() {
|
|
try {
|
|
const raw = localStorage.getItem('napkin_state');
|
|
if (!raw) return;
|
|
const state = JSON.parse(raw);
|
|
if (state.objects) drawingObjects = state.objects;
|
|
if (state.notes) stickyNotes = state.notes;
|
|
if (state.labels) textLabels = state.labels;
|
|
rebuildOverlays();
|
|
render();
|
|
} catch (e) {
|
|
// corrupted state, ignore
|
|
}
|
|
}
|
|
|
|
// ── Share with Copilot ───────────────────────────────────────────
|
|
document.getElementById('share-btn').addEventListener('click', async () => {
|
|
try {
|
|
// Create an offscreen canvas for export
|
|
const exportCanvas = document.createElement('canvas');
|
|
exportCanvas.width = CANVAS_W;
|
|
exportCanvas.height = CANVAS_H;
|
|
const ectx = exportCanvas.getContext('2d');
|
|
|
|
// White background
|
|
ectx.fillStyle = '#fff';
|
|
ectx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
|
|
// Draw all drawing objects
|
|
drawingObjects.forEach(obj => drawObject(ectx, obj));
|
|
|
|
// Draw sticky notes onto export canvas
|
|
stickyNotes.forEach(note => {
|
|
const colors = {
|
|
yellow: { bg: '#fff9c4', header: '#fff176' },
|
|
pink: { bg: '#fce4ec', header: '#f48fb1' },
|
|
blue: { bg: '#e3f2fd', header: '#90caf9' },
|
|
green: { bg: '#e8f5e9', header: '#a5d6a7' }
|
|
};
|
|
const c = colors[note.color] || colors.yellow;
|
|
|
|
// Shadow
|
|
ectx.shadowColor = 'rgba(0,0,0,0.12)';
|
|
ectx.shadowBlur = 12;
|
|
ectx.shadowOffsetX = 2;
|
|
ectx.shadowOffsetY = 3;
|
|
|
|
// Body
|
|
ectx.fillStyle = c.bg;
|
|
ectx.beginPath();
|
|
safeRoundRect(ectx, note.x, note.y, note.w, note.h, 4);
|
|
ectx.fill();
|
|
|
|
// Reset shadow
|
|
ectx.shadowColor = 'transparent';
|
|
ectx.shadowBlur = 0;
|
|
ectx.shadowOffsetX = 0;
|
|
ectx.shadowOffsetY = 0;
|
|
|
|
// Header
|
|
ectx.fillStyle = c.header;
|
|
ectx.beginPath();
|
|
safeRoundRect(ectx, note.x, note.y, note.w, 24, [4, 4, 0, 0]);
|
|
ectx.fill();
|
|
|
|
// Text
|
|
if (note.text) {
|
|
ectx.fillStyle = '#333';
|
|
ectx.font = '14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
const lines = wrapText(ectx, note.text, note.w - 24);
|
|
lines.forEach((line, i) => {
|
|
ectx.fillText(line, note.x + 12, note.y + 44 + i * 20);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Draw text labels
|
|
textLabels.forEach(lbl => {
|
|
if (!lbl.text) return;
|
|
ectx.fillStyle = '#333';
|
|
ectx.font = lbl.fontSize + 'px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
|
ectx.fillText(lbl.text, lbl.x, lbl.y + lbl.fontSize);
|
|
});
|
|
|
|
// Export PNG
|
|
const dataUrl = exportCanvas.toDataURL('image/png');
|
|
const link = document.createElement('a');
|
|
link.download = 'napkin-snapshot.png';
|
|
link.href = dataUrl;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// Build JSON
|
|
const json = {
|
|
version: 1,
|
|
timestamp: new Date().toISOString(),
|
|
notes: stickyNotes.map(n => ({
|
|
id: n.id, text: n.text, x: n.x, y: n.y, color: n.color, width: n.w, height: n.h
|
|
})),
|
|
textLabels: textLabels.map(l => ({
|
|
id: l.id, text: l.text, x: l.x, y: l.y, fontSize: l.fontSize
|
|
})),
|
|
canvasSize: { width: CANVAS_W, height: CANVAS_H }
|
|
};
|
|
|
|
// Copy JSON to clipboard
|
|
try {
|
|
await navigator.clipboard.writeText(JSON.stringify(json, null, 2));
|
|
} catch (clipErr) {
|
|
// Fallback for file:// protocol or older browsers
|
|
const ta = document.createElement('textarea');
|
|
ta.value = JSON.stringify(json, null, 2);
|
|
ta.style.position = 'fixed';
|
|
ta.style.left = '-9999px';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
// Show confirmation
|
|
shareOverlay.classList.remove('hidden');
|
|
|
|
} catch (err) {
|
|
showToast('Export failed: ' + err.message, 4000);
|
|
}
|
|
});
|
|
|
|
document.getElementById('share-overlay-close').addEventListener('click', () => {
|
|
shareOverlay.classList.add('hidden');
|
|
});
|
|
|
|
function wrapText(c, text, maxWidth) {
|
|
const words = text.split(/\s+/);
|
|
const lines = [];
|
|
let currentLine = '';
|
|
words.forEach(word => {
|
|
const test = currentLine ? currentLine + ' ' + word : word;
|
|
if (c.measureText(test).width > maxWidth && currentLine) {
|
|
lines.push(currentLine);
|
|
currentLine = word;
|
|
} else {
|
|
currentLine = test;
|
|
}
|
|
});
|
|
if (currentLine) lines.push(currentLine);
|
|
return lines;
|
|
}
|
|
|
|
// ── Touch support for pinch zoom ─────────────────────────────────
|
|
let lastPinchDist = 0;
|
|
let lastPinchCX = 0, lastPinchCY = 0;
|
|
|
|
container.addEventListener('touchstart', e => {
|
|
if (e.touches.length === 2) {
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
lastPinchDist = Math.hypot(dx, dy);
|
|
lastPinchCX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
lastPinchCY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
}
|
|
}, { passive: true });
|
|
|
|
container.addEventListener('touchmove', e => {
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
const dist = Math.hypot(dx, dy);
|
|
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
|
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
|
|
if (lastPinchDist > 0) {
|
|
const factor = dist / lastPinchDist;
|
|
zoomAt(cx, cy, factor);
|
|
viewX += cx - lastPinchCX;
|
|
viewY += cy - lastPinchCY;
|
|
updateCanvasTransform();
|
|
}
|
|
|
|
lastPinchDist = dist;
|
|
lastPinchCX = cx;
|
|
lastPinchCY = cy;
|
|
}
|
|
}, { passive: false });
|
|
|
|
container.addEventListener('touchend', () => {
|
|
lastPinchDist = 0;
|
|
}, { passive: true });
|
|
|
|
// ── Close overlays on escape ─────────────────────────────────────
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') {
|
|
onboarding.classList.add('hidden');
|
|
shareOverlay.classList.add('hidden');
|
|
noteColorPicker.classList.remove('show');
|
|
document.getElementById('shortcuts-panel').classList.remove('show');
|
|
}
|
|
});
|
|
|
|
// Close note color picker on outside click
|
|
document.addEventListener('pointerdown', e => {
|
|
if (!e.target.closest('#note-color-picker') && !e.target.closest('#note-tool-btn')) {
|
|
noteColorPicker.classList.remove('show');
|
|
}
|
|
});
|
|
|
|
// ── Window resize ────────────────────────────────────────────────
|
|
window.addEventListener('resize', () => {
|
|
updateCanvasTransform();
|
|
});
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────
|
|
initOnboarding();
|
|
initCanvas();
|
|
loadState();
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|