@polderlabs/bizar 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/cli/audit.mjs +144 -0
- package/cli/banner.mjs +41 -0
- package/cli/bin.mjs +186 -0
- package/cli/copy.mjs +508 -0
- package/cli/export.mjs +87 -0
- package/cli/init.mjs +147 -0
- package/cli/install.mjs +390 -0
- package/cli/plan-templates.mjs +523 -0
- package/cli/plan.mjs +2087 -0
- package/cli/prompts.mjs +163 -0
- package/cli/update.mjs +273 -0
- package/cli/utils.mjs +153 -0
- package/config/AGENTS.md +282 -0
- package/config/agents/baldr.md +148 -0
- package/config/agents/forseti.md +112 -0
- package/config/agents/frigg.md +101 -0
- package/config/agents/heimdall.md +157 -0
- package/config/agents/hermod.md +144 -0
- package/config/agents/mimir.md +115 -0
- package/config/agents/odin.md +309 -0
- package/config/agents/quick.md +78 -0
- package/config/agents/semble-search.md +44 -0
- package/config/agents/thor.md +97 -0
- package/config/agents/tyr.md +96 -0
- package/config/agents/vidarr.md +100 -0
- package/config/agents/vor.md +140 -0
- package/config/commands/audit.md +1 -0
- package/config/commands/explain.md +1 -0
- package/config/commands/init.md +1 -0
- package/config/commands/learn.md +1 -0
- package/config/commands/pr-review.md +1 -0
- package/config/commands/tailscale-serve.md +96 -0
- package/config/hooks/README.md +29 -0
- package/config/hooks/post-tool-use.md +16 -0
- package/config/hooks/pre-tool-use.md +16 -0
- package/config/opencode.json +52 -0
- package/config/opencode.json.template +52 -0
- package/config/rules/general.md +8 -0
- package/config/rules/git.md +11 -0
- package/config/rules/javascript.md +10 -0
- package/config/rules/python.md +10 -0
- package/config/rules/testing.md +10 -0
- package/config/skills/bizar/README.md +9 -0
- package/config/skills/bizar/SKILL.md +187 -0
- package/config/skills/cpp-coding-standards/README.md +28 -0
- package/config/skills/cpp-coding-standards/SKILL.md +634 -0
- package/config/skills/cpp-coding-standards/agents/openai.yaml +4 -0
- package/config/skills/cpp-coding-standards/references/concurrency.md +320 -0
- package/config/skills/cpp-coding-standards/references/error-handling.md +229 -0
- package/config/skills/cpp-coding-standards/references/memory-safety.md +216 -0
- package/config/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
- package/config/skills/cpp-coding-standards/references/review-checklist.md +96 -0
- package/config/skills/cpp-testing/README.md +28 -0
- package/config/skills/cpp-testing/SKILL.md +304 -0
- package/config/skills/cpp-testing/agents/openai.yaml +4 -0
- package/config/skills/cpp-testing/references/coverage.md +370 -0
- package/config/skills/cpp-testing/references/framework-compare.md +175 -0
- package/config/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
- package/config/skills/cpp-testing/references/mocking.md +364 -0
- package/config/skills/cpp-testing/references/tdd-workflow.md +308 -0
- package/config/skills/embedded-esp-idf/README.md +41 -0
- package/config/skills/embedded-esp-idf/SKILL.md +439 -0
- package/config/skills/embedded-esp-idf/agents/openai.yaml +4 -0
- package/config/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
- package/config/skills/embedded-esp-idf/references/host-tests.md +164 -0
- package/config/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
- package/config/skills/embedded-esp-idf/references/kconfig.md +159 -0
- package/config/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
- package/config/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
- package/config/skills/embedded-esp-idf/references/nvs.md +121 -0
- package/config/skills/embedded-esp-idf/references/packed-structs.md +192 -0
- package/config/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
- package/config/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
- package/config/skills/self-improvement/SKILL.md +64 -0
- package/package.json +47 -0
- package/templates/plan/htmx.min.js +1 -0
- package/templates/plan/library/bug-investigation.mdx +79 -0
- package/templates/plan/library/decision-record.mdx +71 -0
- package/templates/plan/library/feature-design.mdx +92 -0
- package/templates/plan/meta.json.template +8 -0
- package/templates/plan/plan.canvas.template +1711 -0
- package/templates/plan/plan.html.template +937 -0
- package/templates/plan/plan.mdx.template +46 -0
|
@@ -0,0 +1,1711 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{title}} — Bizar Plan</title>
|
|
7
|
+
|
|
8
|
+
<!-- Self-hosted htmx (no CDN) — used for the comment side panel where
|
|
9
|
+
it shines. Canvas interactions (pan/zoom/drag) are vanilla JS. -->
|
|
10
|
+
<script src="/htmx.min.js"></script>
|
|
11
|
+
|
|
12
|
+
<style>
|
|
13
|
+
/* ============================================================
|
|
14
|
+
Theme tokens (match the v1 viewer for visual continuity)
|
|
15
|
+
============================================================ */
|
|
16
|
+
:root {
|
|
17
|
+
--color-bg: #ffffff;
|
|
18
|
+
--color-text: #1a1a1a;
|
|
19
|
+
--color-muted: #6b7280;
|
|
20
|
+
--color-border: #e5e7eb;
|
|
21
|
+
--color-accent: #3b82f6;
|
|
22
|
+
--color-accent-hover: #2563eb;
|
|
23
|
+
--color-code-bg: #f3f4f6;
|
|
24
|
+
--color-card-bg: #ffffff;
|
|
25
|
+
--color-card-shadow: rgba(0, 0, 0, 0.08);
|
|
26
|
+
--color-button-text: #1a1a1a;
|
|
27
|
+
--color-primary-text: #ffffff;
|
|
28
|
+
--color-save-ok: #10b981;
|
|
29
|
+
--color-save-err: #ef4444;
|
|
30
|
+
--color-pin: #f59e0b;
|
|
31
|
+
--color-pin-hover: #d97706;
|
|
32
|
+
--color-connection: #6b7280;
|
|
33
|
+
--color-connection-arrow: #3b82f6;
|
|
34
|
+
--color-canvas-bg: #f8fafc;
|
|
35
|
+
--color-canvas-grid: #e2e8f0;
|
|
36
|
+
--color-element-selected: #3b82f6;
|
|
37
|
+
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
38
|
+
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (prefers-color-scheme: dark) {
|
|
42
|
+
:root {
|
|
43
|
+
--color-bg: #0f172a;
|
|
44
|
+
--color-text: #e2e8f0;
|
|
45
|
+
--color-muted: #94a3b8;
|
|
46
|
+
--color-border: #334155;
|
|
47
|
+
--color-accent: #60a5fa;
|
|
48
|
+
--color-accent-hover: #93c5fd;
|
|
49
|
+
--color-code-bg: #1e293b;
|
|
50
|
+
--color-card-bg: #1e293b;
|
|
51
|
+
--color-card-shadow: rgba(0, 0, 0, 0.4);
|
|
52
|
+
--color-button-text: #e2e8f0;
|
|
53
|
+
--color-primary-text: #0f172a;
|
|
54
|
+
--color-save-ok: #34d399;
|
|
55
|
+
--color-save-err: #f87171;
|
|
56
|
+
--color-pin: #fbbf24;
|
|
57
|
+
--color-pin-hover: #f59e0b;
|
|
58
|
+
--color-connection: #94a3b8;
|
|
59
|
+
--color-connection-arrow: #60a5fa;
|
|
60
|
+
--color-canvas-bg: #0b1220;
|
|
61
|
+
--color-canvas-grid: #1e293b;
|
|
62
|
+
--color-element-selected: #60a5fa;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ============================================================
|
|
67
|
+
Base
|
|
68
|
+
============================================================ */
|
|
69
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
70
|
+
html, body { height: 100%; margin: 0; padding: 0; }
|
|
71
|
+
body {
|
|
72
|
+
font-family: var(--font-sans);
|
|
73
|
+
background: var(--color-bg);
|
|
74
|
+
color: var(--color-text);
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
overflow: hidden; /* the canvas handles its own scrolling */
|
|
77
|
+
}
|
|
78
|
+
button { font: inherit; cursor: pointer; }
|
|
79
|
+
|
|
80
|
+
/* ============================================================
|
|
81
|
+
Layout
|
|
82
|
+
============================================================ */
|
|
83
|
+
.app { display: flex; flex-direction: column; height: 100vh; }
|
|
84
|
+
.header {
|
|
85
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
86
|
+
padding: 0.5rem 1rem;
|
|
87
|
+
border-bottom: 1px solid var(--color-border);
|
|
88
|
+
background: var(--color-card-bg);
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
z-index: 30;
|
|
91
|
+
}
|
|
92
|
+
.header h1 { margin: 0; font-size: 1.05em; font-weight: 600; }
|
|
93
|
+
.header .meta {
|
|
94
|
+
color: var(--color-muted); font-size: 0.85em; margin-left: 0.5rem;
|
|
95
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
96
|
+
}
|
|
97
|
+
.header-actions { margin-left: auto; display: flex; gap: 0.5rem; align-items: center; }
|
|
98
|
+
.save-status { font-size: 0.8em; color: var(--color-muted); margin-left: 0.5rem; }
|
|
99
|
+
.save-status.saved { color: var(--color-save-ok); }
|
|
100
|
+
.save-status.error { color: var(--color-save-err); }
|
|
101
|
+
|
|
102
|
+
/* ============================================================
|
|
103
|
+
Toolbar
|
|
104
|
+
============================================================ */
|
|
105
|
+
.toolbar {
|
|
106
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
107
|
+
padding: 0.5rem 1rem;
|
|
108
|
+
border-bottom: 1px solid var(--color-border);
|
|
109
|
+
background: var(--color-card-bg);
|
|
110
|
+
flex-shrink: 0;
|
|
111
|
+
z-index: 20;
|
|
112
|
+
}
|
|
113
|
+
.toolbar button {
|
|
114
|
+
border: 1px solid var(--color-border);
|
|
115
|
+
background: var(--color-card-bg);
|
|
116
|
+
color: var(--color-button-text);
|
|
117
|
+
padding: 0.4rem 0.75rem;
|
|
118
|
+
border-radius: 6px;
|
|
119
|
+
font-size: 0.9em;
|
|
120
|
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
121
|
+
}
|
|
122
|
+
.toolbar button:hover { background: var(--color-code-bg); }
|
|
123
|
+
.toolbar button:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
|
124
|
+
.toolbar button.active {
|
|
125
|
+
background: var(--color-accent);
|
|
126
|
+
color: var(--color-primary-text);
|
|
127
|
+
border-color: var(--color-accent);
|
|
128
|
+
}
|
|
129
|
+
.toolbar .sep {
|
|
130
|
+
width: 1px; height: 1.5rem; background: var(--color-border); margin: 0 0.25rem;
|
|
131
|
+
}
|
|
132
|
+
.toolbar .zoom-controls { display: flex; align-items: center; gap: 0.25rem; }
|
|
133
|
+
.toolbar .zoom-controls .zoom-level { font-size: 0.8em; color: var(--color-muted); min-width: 3.5em; text-align: center; }
|
|
134
|
+
.toolbar .spacer { flex: 1; }
|
|
135
|
+
.toolbar .view-toggle { font-size: 0.85em; color: var(--color-muted); }
|
|
136
|
+
|
|
137
|
+
/* ============================================================
|
|
138
|
+
Canvas (infinite scrollable surface)
|
|
139
|
+
============================================================ */
|
|
140
|
+
.canvas-wrap {
|
|
141
|
+
position: relative; flex: 1; min-height: 0;
|
|
142
|
+
background: var(--color-canvas-bg);
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
}
|
|
145
|
+
.canvas {
|
|
146
|
+
position: absolute; top: 0; left: 0;
|
|
147
|
+
width: 5000px; height: 5000px; /* large virtual area */
|
|
148
|
+
transform-origin: 0 0;
|
|
149
|
+
will-change: transform;
|
|
150
|
+
}
|
|
151
|
+
.canvas.panning { cursor: grabbing; }
|
|
152
|
+
.canvas.space-down { cursor: grab; }
|
|
153
|
+
|
|
154
|
+
/* Subtle dot grid background, painted via SVG via background-image fallback */
|
|
155
|
+
.canvas::before {
|
|
156
|
+
content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
|
157
|
+
background-image: radial-gradient(circle, var(--color-canvas-grid) 1px, transparent 1px);
|
|
158
|
+
background-size: 24px 24px;
|
|
159
|
+
pointer-events: none;
|
|
160
|
+
opacity: 0.6;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* SVG layer for connections — sits behind elements but in front of the grid */
|
|
164
|
+
#connections-layer {
|
|
165
|
+
position: absolute; top: 0; left: 0;
|
|
166
|
+
width: 100%; height: 100%;
|
|
167
|
+
pointer-events: none; /* let the canvas receive pan events; individual
|
|
168
|
+
<path>s re-enable pointer-events: stroke below */
|
|
169
|
+
z-index: 1;
|
|
170
|
+
}
|
|
171
|
+
#connections-layer path {
|
|
172
|
+
pointer-events: stroke;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
}
|
|
175
|
+
#connections-layer .connection-hit { stroke-width: 14; stroke: transparent; fill: none; }
|
|
176
|
+
#connections-layer .connection-line { stroke: var(--color-connection); stroke-width: 2; fill: none; }
|
|
177
|
+
#connections-layer .connection-arrow.arrow { marker-end: url(#arrowhead); stroke: var(--color-connection-arrow); }
|
|
178
|
+
#connections-layer .connection-arrow.dependency { marker-end: url(#arrowhead-dep); stroke-dasharray: 6 4; stroke: var(--color-connection); }
|
|
179
|
+
#connections-layer .connection-label { fill: var(--color-text); font: 12px var(--font-sans); paint-order: stroke; stroke: var(--color-card-bg); stroke-width: 4; }
|
|
180
|
+
|
|
181
|
+
/* ============================================================
|
|
182
|
+
Elements
|
|
183
|
+
============================================================ */
|
|
184
|
+
.element {
|
|
185
|
+
position: absolute;
|
|
186
|
+
background: var(--color-card-bg);
|
|
187
|
+
border: 1px solid var(--color-border);
|
|
188
|
+
border-radius: 6px;
|
|
189
|
+
box-shadow: 0 1px 3px var(--color-card-shadow);
|
|
190
|
+
display: flex; flex-direction: column;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
z-index: 5;
|
|
193
|
+
min-width: 80px; min-height: 40px;
|
|
194
|
+
}
|
|
195
|
+
.element:hover { box-shadow: 0 2px 8px var(--color-card-shadow); }
|
|
196
|
+
.element.selected { border-color: var(--color-element-selected); box-shadow: 0 0 0 2px var(--color-element-selected); }
|
|
197
|
+
.element.connect-source { border-color: var(--color-connection-arrow); }
|
|
198
|
+
|
|
199
|
+
.element-header {
|
|
200
|
+
display: flex; align-items: center; gap: 0.4rem;
|
|
201
|
+
padding: 0.35rem 0.5rem;
|
|
202
|
+
background: var(--color-code-bg);
|
|
203
|
+
border-bottom: 1px solid var(--color-border);
|
|
204
|
+
cursor: move;
|
|
205
|
+
font-size: 0.85em;
|
|
206
|
+
user-select: none;
|
|
207
|
+
}
|
|
208
|
+
.element-header .type-badge {
|
|
209
|
+
font-size: 0.75em; color: var(--color-muted);
|
|
210
|
+
background: var(--color-bg);
|
|
211
|
+
border: 1px solid var(--color-border);
|
|
212
|
+
border-radius: 4px;
|
|
213
|
+
padding: 0.1em 0.4em;
|
|
214
|
+
flex-shrink: 0;
|
|
215
|
+
}
|
|
216
|
+
.element-header .title {
|
|
217
|
+
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
218
|
+
}
|
|
219
|
+
.element-header .actions { display: flex; gap: 0.25rem; flex-shrink: 0; }
|
|
220
|
+
.element-header .actions button {
|
|
221
|
+
background: transparent; border: 0; padding: 0.1rem 0.35rem;
|
|
222
|
+
color: var(--color-muted); font-size: 0.85em; cursor: pointer;
|
|
223
|
+
border-radius: 3px;
|
|
224
|
+
}
|
|
225
|
+
.element-header .actions button:hover { background: var(--color-border); color: var(--color-text); }
|
|
226
|
+
|
|
227
|
+
.element-body { flex: 1; overflow: auto; padding: 0.5rem 0.75rem; }
|
|
228
|
+
.element-body pre {
|
|
229
|
+
margin: 0; font-family: var(--font-mono); font-size: 0.85em;
|
|
230
|
+
white-space: pre-wrap; word-wrap: break-word;
|
|
231
|
+
}
|
|
232
|
+
.element-body img { max-width: 100%; height: auto; display: block; }
|
|
233
|
+
.element-body code { font-family: var(--font-mono); font-size: 0.85em; }
|
|
234
|
+
.element-body .ui-mockup-button {
|
|
235
|
+
display: inline-block; padding: 0.4rem 1rem;
|
|
236
|
+
background: var(--color-accent); color: var(--color-primary-text);
|
|
237
|
+
border: 0; border-radius: 4px; font-size: 0.95em;
|
|
238
|
+
}
|
|
239
|
+
.element-body .ui-mockup-input {
|
|
240
|
+
display: block; width: 100%; padding: 0.4rem 0.6rem;
|
|
241
|
+
font: inherit; border: 1px solid var(--color-border); border-radius: 4px;
|
|
242
|
+
background: var(--color-bg); color: var(--color-text);
|
|
243
|
+
}
|
|
244
|
+
.element-body .ui-mockup-card {
|
|
245
|
+
padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 6px;
|
|
246
|
+
}
|
|
247
|
+
.element-body .ui-mockup-card h4 { margin: 0 0 0.5rem; font-size: 1em; }
|
|
248
|
+
.element-body .ui-mockup-card p { margin: 0; color: var(--color-muted); }
|
|
249
|
+
|
|
250
|
+
.element-body[contenteditable="true"] { outline: 2px solid var(--color-accent); outline-offset: -2px; }
|
|
251
|
+
|
|
252
|
+
.resize-handle {
|
|
253
|
+
position: absolute; right: 0; bottom: 0; width: 12px; height: 12px;
|
|
254
|
+
cursor: nwse-resize;
|
|
255
|
+
background: linear-gradient(135deg, transparent 50%, var(--color-muted) 50%);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ============================================================
|
|
259
|
+
Comment pins
|
|
260
|
+
============================================================ */
|
|
261
|
+
.comment-pin {
|
|
262
|
+
position: absolute;
|
|
263
|
+
width: 26px; height: 26px;
|
|
264
|
+
border-radius: 50%;
|
|
265
|
+
background: var(--color-pin);
|
|
266
|
+
color: #fff;
|
|
267
|
+
display: flex; align-items: center; justify-content: center;
|
|
268
|
+
font-size: 0.8em; font-weight: 600;
|
|
269
|
+
cursor: grab;
|
|
270
|
+
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
|
271
|
+
z-index: 10;
|
|
272
|
+
user-select: none;
|
|
273
|
+
}
|
|
274
|
+
.comment-pin:hover { background: var(--color-pin-hover); }
|
|
275
|
+
.comment-pin.active {
|
|
276
|
+
outline: 3px solid var(--color-accent);
|
|
277
|
+
outline-offset: 2px;
|
|
278
|
+
}
|
|
279
|
+
.comment-pin.dragging { cursor: grabbing; }
|
|
280
|
+
|
|
281
|
+
/* ============================================================
|
|
282
|
+
Comment side panel
|
|
283
|
+
============================================================ */
|
|
284
|
+
.comment-panel {
|
|
285
|
+
position: fixed; top: 0; right: 0;
|
|
286
|
+
width: 420px; max-width: 100vw; height: 100vh;
|
|
287
|
+
background: var(--color-card-bg);
|
|
288
|
+
box-shadow: -2px 0 12px var(--color-card-shadow);
|
|
289
|
+
transform: translateX(100%);
|
|
290
|
+
transition: transform 0.25s ease-out;
|
|
291
|
+
z-index: 100;
|
|
292
|
+
display: flex; flex-direction: column;
|
|
293
|
+
box-sizing: border-box;
|
|
294
|
+
}
|
|
295
|
+
.comment-panel.open { transform: translateX(0); }
|
|
296
|
+
.comment-panel-header {
|
|
297
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
298
|
+
padding: 1rem 1.25rem;
|
|
299
|
+
border-bottom: 1px solid var(--color-border);
|
|
300
|
+
flex-shrink: 0;
|
|
301
|
+
}
|
|
302
|
+
.comment-panel-header h3 { margin: 0; font-size: 1em; }
|
|
303
|
+
#close-comment-panel {
|
|
304
|
+
background: transparent; border: 0; font-size: 1.4em; line-height: 1;
|
|
305
|
+
padding: 0.25rem 0.5rem; color: var(--color-muted); cursor: pointer;
|
|
306
|
+
}
|
|
307
|
+
#close-comment-panel:hover { color: var(--color-text); }
|
|
308
|
+
|
|
309
|
+
.comment-list { list-style: none; padding: 0; margin: 0; overflow-y: auto; flex: 1; }
|
|
310
|
+
.comment { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--color-border); }
|
|
311
|
+
.comment-meta { font-size: 0.8em; color: var(--color-muted); margin-bottom: 0.25rem; }
|
|
312
|
+
.comment-text { white-space: pre-wrap; word-wrap: break-word; }
|
|
313
|
+
.reply {
|
|
314
|
+
margin-top: 0.5rem; margin-left: 1rem;
|
|
315
|
+
padding-left: 0.75rem; border-left: 2px solid var(--color-accent);
|
|
316
|
+
}
|
|
317
|
+
.reply-meta { font-size: 0.75em; color: var(--color-muted); margin-bottom: 0.15rem; }
|
|
318
|
+
.reply-text { white-space: pre-wrap; word-wrap: break-word; font-size: 0.9em; }
|
|
319
|
+
.empty { color: var(--color-muted); text-align: center; padding: 2rem 1rem; font-style: italic; list-style: none; }
|
|
320
|
+
|
|
321
|
+
.comment-form {
|
|
322
|
+
border-top: 1px solid var(--color-border);
|
|
323
|
+
padding: 0.75rem 1.25rem;
|
|
324
|
+
flex-shrink: 0;
|
|
325
|
+
}
|
|
326
|
+
.comment-form textarea {
|
|
327
|
+
width: 100%; min-height: 70px; font: inherit;
|
|
328
|
+
background: var(--color-bg); color: var(--color-text);
|
|
329
|
+
border: 1px solid var(--color-border); border-radius: 6px;
|
|
330
|
+
padding: 0.5rem; resize: vertical; box-sizing: border-box;
|
|
331
|
+
}
|
|
332
|
+
.comment-form-actions {
|
|
333
|
+
display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem;
|
|
334
|
+
}
|
|
335
|
+
.comment-form .hint { font-size: 0.8em; color: var(--color-muted); margin-left: auto; }
|
|
336
|
+
|
|
337
|
+
/* ============================================================
|
|
338
|
+
Modal: add/edit element
|
|
339
|
+
============================================================ */
|
|
340
|
+
.modal-backdrop {
|
|
341
|
+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
342
|
+
background: rgba(0,0,0,0.4);
|
|
343
|
+
display: none; align-items: center; justify-content: center;
|
|
344
|
+
z-index: 200;
|
|
345
|
+
}
|
|
346
|
+
.modal-backdrop.open { display: flex; }
|
|
347
|
+
.modal {
|
|
348
|
+
background: var(--color-card-bg);
|
|
349
|
+
border-radius: 8px;
|
|
350
|
+
padding: 1.25rem;
|
|
351
|
+
width: 480px; max-width: 92vw;
|
|
352
|
+
max-height: 90vh; overflow-y: auto;
|
|
353
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
|
354
|
+
}
|
|
355
|
+
.modal h2 { margin: 0 0 1rem; font-size: 1.1em; }
|
|
356
|
+
.modal label { display: block; margin-bottom: 0.75rem; font-size: 0.9em; }
|
|
357
|
+
.modal label span { display: block; margin-bottom: 0.25rem; color: var(--color-muted); font-size: 0.85em; }
|
|
358
|
+
.modal input, .modal textarea, .modal select {
|
|
359
|
+
width: 100%; padding: 0.4rem 0.6rem; font: inherit;
|
|
360
|
+
border: 1px solid var(--color-border); border-radius: 4px;
|
|
361
|
+
background: var(--color-bg); color: var(--color-text);
|
|
362
|
+
box-sizing: border-box;
|
|
363
|
+
}
|
|
364
|
+
.modal textarea { min-height: 100px; resize: vertical; }
|
|
365
|
+
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
|
366
|
+
.modal button {
|
|
367
|
+
padding: 0.45rem 0.9rem; border: 1px solid var(--color-border);
|
|
368
|
+
background: var(--color-card-bg); color: var(--color-button-text);
|
|
369
|
+
border-radius: 4px; cursor: pointer;
|
|
370
|
+
}
|
|
371
|
+
.modal button.primary { background: var(--color-accent); color: var(--color-primary-text); border-color: var(--color-accent); }
|
|
372
|
+
.modal button:hover { background: var(--color-code-bg); }
|
|
373
|
+
.modal button.primary:hover { background: var(--color-accent-hover); border-color: var(--color-accent-hover); }
|
|
374
|
+
|
|
375
|
+
/* ============================================================
|
|
376
|
+
Misc
|
|
377
|
+
============================================================ */
|
|
378
|
+
.empty-canvas {
|
|
379
|
+
position: absolute; top: 50%; left: 50%;
|
|
380
|
+
transform: translate(-50%, -50%);
|
|
381
|
+
color: var(--color-muted); font-style: italic;
|
|
382
|
+
pointer-events: none;
|
|
383
|
+
text-align: center;
|
|
384
|
+
}
|
|
385
|
+
.empty-canvas.hidden { display: none; }
|
|
386
|
+
|
|
387
|
+
/* hide the cursor while we draw a connection line */
|
|
388
|
+
.canvas.connecting { cursor: crosshair; }
|
|
389
|
+
|
|
390
|
+
@media (max-width: 700px) {
|
|
391
|
+
.comment-panel { width: 100%; }
|
|
392
|
+
.header h1 { font-size: 0.95em; }
|
|
393
|
+
.toolbar button { padding: 0.35rem 0.5rem; font-size: 0.85em; }
|
|
394
|
+
}
|
|
395
|
+
</style>
|
|
396
|
+
</head>
|
|
397
|
+
<body>
|
|
398
|
+
<div class="app">
|
|
399
|
+
<!-- ── Header ────────────────────────────────────────────────── -->
|
|
400
|
+
<header class="header">
|
|
401
|
+
<h1 id="header-title">{{title}}</h1>
|
|
402
|
+
<span class="meta" id="header-meta">
|
|
403
|
+
Slug: {{slug}} · Status: {{status}} · Author: {{author}}
|
|
404
|
+
</span>
|
|
405
|
+
<div class="header-actions">
|
|
406
|
+
<button id="export-md-btn" type="button" title="Export as Markdown">📤 Export</button>
|
|
407
|
+
<span id="save-status" class="save-status" aria-live="polite"></span>
|
|
408
|
+
</div>
|
|
409
|
+
</header>
|
|
410
|
+
|
|
411
|
+
<!-- ── Toolbar ───────────────────────────────────────────────── -->
|
|
412
|
+
<div class="toolbar" role="toolbar" aria-label="Canvas tools">
|
|
413
|
+
<button data-tool="select" class="active" title="Select / pan (Esc)">↖ Select</button>
|
|
414
|
+
<button data-tool="text" title="Add a text block (T)">📝 Text</button>
|
|
415
|
+
<button data-tool="image" title="Add an image (I)">🖼 Image</button>
|
|
416
|
+
<button data-tool="code" title="Add a code block (C)">💻 Code</button>
|
|
417
|
+
<button data-tool="diagram" title="Add a mermaid diagram (D)">📊 Diagram</button>
|
|
418
|
+
<button data-tool="ui-mockup" title="Add a UI mockup (U)">🎨 UI Mockup</button>
|
|
419
|
+
<span class="sep"></span>
|
|
420
|
+
<button data-tool="connect" title="Draw a connection between elements (L)">↔ Connect</button>
|
|
421
|
+
<button data-tool="comment" title="Click to drop a comment pin (M)">💬 Comment</button>
|
|
422
|
+
<span class="spacer"></span>
|
|
423
|
+
<div class="zoom-controls" aria-label="Zoom">
|
|
424
|
+
<button id="zoom-out" type="button" title="Zoom out (-)">−</button>
|
|
425
|
+
<span class="zoom-level" id="zoom-level">100%</span>
|
|
426
|
+
<button id="zoom-in" type="button" title="Zoom in (+)">+</button>
|
|
427
|
+
<button id="zoom-reset" type="button" title="Reset zoom (0)">⤢</button>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<!-- ── Canvas ────────────────────────────────────────────────── -->
|
|
432
|
+
<div class="canvas-wrap" id="canvas-wrap">
|
|
433
|
+
<div class="canvas" id="canvas">
|
|
434
|
+
<svg id="connections-layer" xmlns="http://www.w3.org/2000/svg">
|
|
435
|
+
<defs>
|
|
436
|
+
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
|
|
437
|
+
markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
|
438
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-connection-arrow)"></path>
|
|
439
|
+
</marker>
|
|
440
|
+
<marker id="arrowhead-dep" viewBox="0 0 10 10" refX="9" refY="5"
|
|
441
|
+
markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
|
442
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--color-connection)"></path>
|
|
443
|
+
</marker>
|
|
444
|
+
</defs>
|
|
445
|
+
<g id="connections-g"></g>
|
|
446
|
+
<g id="pending-connection-g"></g>
|
|
447
|
+
</svg>
|
|
448
|
+
<div id="elements-layer" aria-label="Canvas elements"></div>
|
|
449
|
+
<div id="comments-layer" aria-label="Canvas comments"></div>
|
|
450
|
+
<div class="empty-canvas" id="empty-canvas-msg">
|
|
451
|
+
Empty canvas — pick a tool from the toolbar to add an element.
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<!-- ── Comment side panel ──────────────────────────────────────── -->
|
|
458
|
+
<aside id="comment-panel" class="comment-panel" aria-hidden="true">
|
|
459
|
+
<div class="comment-panel-header">
|
|
460
|
+
<h3>Comments <span id="comment-target" style="font-weight: normal; color: var(--color-muted);"></span></h3>
|
|
461
|
+
<button id="close-comment-panel" type="button" aria-label="Close comments">×</button>
|
|
462
|
+
</div>
|
|
463
|
+
<ul id="comment-list" class="comment-list"></ul>
|
|
464
|
+
<!-- Reply form: htmx PUTs to /api/<slug>/comments/<id>; the response is
|
|
465
|
+
the full updated <li class="comment"> thread which htmx swaps in
|
|
466
|
+
place of the current thread (outerHTML on #comment-list).
|
|
467
|
+
The hx-put URL is updated by JS via setAttribute() when a
|
|
468
|
+
comment is opened. The placeholder hx-put here keeps the form
|
|
469
|
+
semantically valid (and testable) before any comment is opened. -->
|
|
470
|
+
<form class="comment-form" id="comment-form" autocomplete="off"
|
|
471
|
+
hx-put="/api/{{slug}}/comments/_"
|
|
472
|
+
hx-target="#comment-list"
|
|
473
|
+
hx-swap="outerHTML"
|
|
474
|
+
hx-trigger="submit">
|
|
475
|
+
<textarea name="reply" id="new-comment" placeholder="Reply or add a comment…"></textarea>
|
|
476
|
+
<input type="hidden" name="replyAuthor" id="reply-author">
|
|
477
|
+
<div class="comment-form-actions">
|
|
478
|
+
<button type="submit" class="primary" id="comment-submit">Add</button>
|
|
479
|
+
<span class="hint">⌘/Ctrl+Enter</span>
|
|
480
|
+
</div>
|
|
481
|
+
</form>
|
|
482
|
+
</aside>
|
|
483
|
+
|
|
484
|
+
<!-- ── Add element modal ──────────────────────────────────────── -->
|
|
485
|
+
<div class="modal-backdrop" id="modal-backdrop" aria-hidden="true">
|
|
486
|
+
<div class="modal" role="dialog" aria-labelledby="modal-title">
|
|
487
|
+
<h2 id="modal-title">Add element</h2>
|
|
488
|
+
<!-- htmx attributes are set by JS based on whether we're adding or
|
|
489
|
+
editing (hx-post vs hx-put). hx-target is the elements layer so
|
|
490
|
+
the new element is appended. hx-swap="beforeend" only applies on
|
|
491
|
+
POST; for PUT (edit) we use "outerHTML" via JS (see submitElementForm). -->
|
|
492
|
+
<form id="element-form"
|
|
493
|
+
hx-post="/api/{{slug}}/elements"
|
|
494
|
+
hx-target="#elements-layer"
|
|
495
|
+
hx-swap="beforeend"
|
|
496
|
+
hx-on::after-request="if(event.detail.successful && !window.__elementEditId) { this.dispatchEvent(new CustomEvent('element-added', {bubbles:true, detail:event.detail})); }">
|
|
497
|
+
<label id="modal-type-row" hidden>
|
|
498
|
+
<span>Type</span>
|
|
499
|
+
<select id="el-type" name="type">
|
|
500
|
+
<option value="text">Text</option>
|
|
501
|
+
<option value="image">Image</option>
|
|
502
|
+
<option value="code">Code</option>
|
|
503
|
+
<option value="diagram">Mermaid diagram</option>
|
|
504
|
+
<option value="ui-mockup">UI mockup</option>
|
|
505
|
+
</select>
|
|
506
|
+
</label>
|
|
507
|
+
<label>
|
|
508
|
+
<span>Title (optional)</span>
|
|
509
|
+
<input type="text" id="el-title" name="title" placeholder="A short label">
|
|
510
|
+
</label>
|
|
511
|
+
<label id="el-language-row" hidden>
|
|
512
|
+
<span>Language (for code blocks)</span>
|
|
513
|
+
<input type="text" id="el-language" name="language" placeholder="javascript">
|
|
514
|
+
</label>
|
|
515
|
+
<label id="el-content-row">
|
|
516
|
+
<span id="el-content-label">Content</span>
|
|
517
|
+
<textarea id="el-content" name="content" placeholder="Write the text content…"></textarea>
|
|
518
|
+
</label>
|
|
519
|
+
<label id="el-component-row" hidden>
|
|
520
|
+
<span>UI component</span>
|
|
521
|
+
<select id="el-component" name="component">
|
|
522
|
+
<option value="button">Button</option>
|
|
523
|
+
<option value="input">Input field</option>
|
|
524
|
+
<option value="card">Card</option>
|
|
525
|
+
</select>
|
|
526
|
+
</label>
|
|
527
|
+
<label id="el-label-row" hidden>
|
|
528
|
+
<span>Button label</span>
|
|
529
|
+
<input type="text" id="el-label" name="label" placeholder="Submit">
|
|
530
|
+
</label>
|
|
531
|
+
<label id="el-placeholder-row" hidden>
|
|
532
|
+
<span>Input placeholder</span>
|
|
533
|
+
<input type="text" id="el-placeholder" name="placeholder" placeholder="Email">
|
|
534
|
+
</label>
|
|
535
|
+
<label id="el-value-row" hidden>
|
|
536
|
+
<span>Input value</span>
|
|
537
|
+
<input type="text" id="el-value" name="value" placeholder="">
|
|
538
|
+
</label>
|
|
539
|
+
<label id="el-card-body-row" hidden>
|
|
540
|
+
<span>Card body</span>
|
|
541
|
+
<textarea id="el-card-body" name="body" placeholder="Body text"></textarea>
|
|
542
|
+
</label>
|
|
543
|
+
<!-- x/y/width/height are filled in by JS just before submit so the
|
|
544
|
+
new element lands in the centre of the current view. -->
|
|
545
|
+
<input type="hidden" id="el-x" name="x">
|
|
546
|
+
<input type="hidden" id="el-y" name="y">
|
|
547
|
+
<input type="hidden" id="el-width" name="width">
|
|
548
|
+
<input type="hidden" id="el-height" name="height">
|
|
549
|
+
<div class="modal-actions">
|
|
550
|
+
<button type="button" id="modal-cancel">Cancel</button>
|
|
551
|
+
<button type="submit" class="primary" id="modal-ok">Add</button>
|
|
552
|
+
</div>
|
|
553
|
+
</form>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
<!-- ── Embedded state (baked in via {{…}} substitution) ─────── -->
|
|
558
|
+
<script id="meta-source" type="application/json">{{metaJson}}</script>
|
|
559
|
+
<script id="canvas-source" type="application/json">{{canvasJson}}</script>
|
|
560
|
+
|
|
561
|
+
<script>
|
|
562
|
+
/* ============================================================
|
|
563
|
+
* Bizar Plan — Canvas viewer
|
|
564
|
+
*
|
|
565
|
+
* State model (v2):
|
|
566
|
+
* - elements: positioned, draggable cards (text/image/code/diagram/ui-mockup)
|
|
567
|
+
* - connections: SVG arrows/lines between two element ids
|
|
568
|
+
* - comments: pins placed at (x,y) on the canvas, optionally
|
|
569
|
+
* attached to an elementId; each comment has a thread
|
|
570
|
+
* of replies.
|
|
571
|
+
* - viewport: { x, y, zoom } — pan & zoom state
|
|
572
|
+
*
|
|
573
|
+
* Interaction model:
|
|
574
|
+
* - Pan: hold space + drag, or middle-mouse drag
|
|
575
|
+
* - Zoom: Ctrl+scroll, or +/-/reset buttons
|
|
576
|
+
* - Drag: grab the element header and drag
|
|
577
|
+
* - Edit: double-click an element to edit content inline
|
|
578
|
+
* - Connect: switch to Connect tool, click first element, then second
|
|
579
|
+
* - Comment: switch to Comment tool, click anywhere on the canvas
|
|
580
|
+
*
|
|
581
|
+
* Persistence: every change autosaves via PUT /api/<slug>/canvas.
|
|
582
|
+
* ============================================================ */
|
|
583
|
+
(function () {
|
|
584
|
+
'use strict';
|
|
585
|
+
|
|
586
|
+
// ── Embedded state (baked in via {{…}}) ─────────────────────
|
|
587
|
+
var META = {{metaJson}};
|
|
588
|
+
var INITIAL_CANVAS = {{canvasJson}};
|
|
589
|
+
|
|
590
|
+
var SLUG = (META && META.slug) || '{{slug}}';
|
|
591
|
+
var TITLE = (META && META.title) || '{{title}}';
|
|
592
|
+
var AUTHOR = (META && META.author) || (typeof process !== 'undefined' && process.env && process.env.USER) || 'anonymous';
|
|
593
|
+
|
|
594
|
+
// ── In-memory state ─────────────────────────────────────────
|
|
595
|
+
// Live state — what the UI renders from, and what we PUT back.
|
|
596
|
+
var state = normalizeCanvas(INITIAL_CANVAS, TITLE);
|
|
597
|
+
var activeTool = 'select';
|
|
598
|
+
var selectedElementId = null;
|
|
599
|
+
var activeCommentId = null; // currently-open comment in side panel
|
|
600
|
+
var connectFromId = null; // first element of a pending connection
|
|
601
|
+
var pendingPin = null; // { x, y } while user clicks to drop a pin
|
|
602
|
+
var spaceDown = false; // spacebar held = pan mode
|
|
603
|
+
var panState = null; // { startX, startY, origX, origY }
|
|
604
|
+
var resizeState = null; // { id, startX, startY, origW, origH }
|
|
605
|
+
|
|
606
|
+
// ── DOM helpers ─────────────────────────────────────────────
|
|
607
|
+
function $(sel) { return document.querySelector(sel); }
|
|
608
|
+
function $$(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
|
|
609
|
+
function el(tag, attrs, children) {
|
|
610
|
+
var node = document.createElement(tag);
|
|
611
|
+
if (attrs) for (var k in attrs) {
|
|
612
|
+
if (k === 'class') node.className = attrs[k];
|
|
613
|
+
else if (k === 'text') node.textContent = attrs[k];
|
|
614
|
+
else if (k.indexOf('on') === 0) node.addEventListener(k.slice(2), attrs[k]);
|
|
615
|
+
else if (k === 'dataset') for (var d in attrs[k]) node.dataset[d] = attrs[k][d];
|
|
616
|
+
else node.setAttribute(k, attrs[k]);
|
|
617
|
+
}
|
|
618
|
+
if (children) {
|
|
619
|
+
if (!Array.isArray(children)) children = [children];
|
|
620
|
+
for (var i = 0; i < children.length; i++) {
|
|
621
|
+
var c = children[i];
|
|
622
|
+
if (c == null) continue;
|
|
623
|
+
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
|
|
624
|
+
else node.appendChild(c);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return node;
|
|
628
|
+
}
|
|
629
|
+
function svgEl(tag, attrs) {
|
|
630
|
+
var node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
631
|
+
if (attrs) for (var k in attrs) node.setAttribute(k, attrs[k]);
|
|
632
|
+
return node;
|
|
633
|
+
}
|
|
634
|
+
function escapeHtml(s) {
|
|
635
|
+
return String(s == null ? '' : s)
|
|
636
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
637
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── State normalization ─────────────────────────────────────
|
|
641
|
+
function normalizeCanvas(raw, fallbackTitle) {
|
|
642
|
+
if (raw == null || typeof raw !== 'object') {
|
|
643
|
+
return emptyCanvas(fallbackTitle);
|
|
644
|
+
}
|
|
645
|
+
var c = {
|
|
646
|
+
schemaVersion: raw.schemaVersion || 2,
|
|
647
|
+
title: typeof raw.title === 'string' ? raw.title : (fallbackTitle || 'Untitled'),
|
|
648
|
+
elements: Array.isArray(raw.elements) ? raw.elements : [],
|
|
649
|
+
connections: Array.isArray(raw.connections) ? raw.connections : [],
|
|
650
|
+
comments: Array.isArray(raw.comments) ? raw.comments : [],
|
|
651
|
+
viewport: raw.viewport && typeof raw.viewport === 'object'
|
|
652
|
+
? raw.viewport : { x: 0, y: 0, zoom: 1 },
|
|
653
|
+
};
|
|
654
|
+
return c;
|
|
655
|
+
}
|
|
656
|
+
function emptyCanvas(title) {
|
|
657
|
+
return {
|
|
658
|
+
schemaVersion: 2,
|
|
659
|
+
title: title || 'Untitled',
|
|
660
|
+
elements: [], connections: [], comments: [],
|
|
661
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── Save status indicator ───────────────────────────────────
|
|
666
|
+
function setSaveStatus(text, kind) {
|
|
667
|
+
var s = $('#save-status');
|
|
668
|
+
if (!s) return;
|
|
669
|
+
s.textContent = text || '';
|
|
670
|
+
s.className = 'save-status' + (kind ? ' ' + kind : '');
|
|
671
|
+
}
|
|
672
|
+
var saveDebounce = null;
|
|
673
|
+
function scheduleSave() {
|
|
674
|
+
if (saveDebounce) clearTimeout(saveDebounce);
|
|
675
|
+
setSaveStatus('Saving…');
|
|
676
|
+
saveDebounce = setTimeout(saveNow, 500);
|
|
677
|
+
}
|
|
678
|
+
function saveNow() {
|
|
679
|
+
saveDebounce = null;
|
|
680
|
+
// htmx.ajax does the PUT for us. The server returns a saved-status
|
|
681
|
+
// <span> which htmx swaps into #save-status. On error, htmx fires
|
|
682
|
+
// htmx:responseError; we listen for it in wire() to show the message.
|
|
683
|
+
try {
|
|
684
|
+
window.htmx.ajax('PUT', '/api/' + encodeURIComponent(SLUG) + '/canvas', {
|
|
685
|
+
source: '#save-status',
|
|
686
|
+
target: '#save-status',
|
|
687
|
+
swap: 'innerHTML',
|
|
688
|
+
values: state,
|
|
689
|
+
headers: { 'Content-Type': 'application/json' },
|
|
690
|
+
});
|
|
691
|
+
} catch (err) {
|
|
692
|
+
setSaveStatus('Save failed: ' + err.message, 'error');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ── Coordinate conversion ───────────────────────────────────
|
|
697
|
+
// The canvas is a 5000×5000 surface translated by viewport.x, viewport.y
|
|
698
|
+
// and scaled by viewport.zoom. Anything inside the canvas is in
|
|
699
|
+
// "world" coordinates; clicks/mouse events arrive in "screen" coords.
|
|
700
|
+
function screenToWorld(sx, sy) {
|
|
701
|
+
var r = $('#canvas').getBoundingClientRect();
|
|
702
|
+
// r.left/r.top is the top-left of the canvas in screen coords.
|
|
703
|
+
// World point of (sx, sy) is:
|
|
704
|
+
// worldX = (sx - r.left - viewport.x) / zoom
|
|
705
|
+
// worldY = (sy - r.top - viewport.y) / zoom
|
|
706
|
+
var wx = (sx - r.left - state.viewport.x) / state.viewport.zoom;
|
|
707
|
+
var wy = (sy - r.top - state.viewport.y) / state.viewport.zoom;
|
|
708
|
+
return { x: wx, y: wy };
|
|
709
|
+
}
|
|
710
|
+
function applyViewport() {
|
|
711
|
+
var canvas = $('#canvas');
|
|
712
|
+
canvas.style.transform =
|
|
713
|
+
'translate(' + state.viewport.x + 'px, ' + state.viewport.y + 'px) scale(' + state.viewport.zoom + ')';
|
|
714
|
+
$('#zoom-level').textContent = Math.round(state.viewport.zoom * 100) + '%';
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── Element rendering ───────────────────────────────────────
|
|
718
|
+
function findElement(id) {
|
|
719
|
+
for (var i = 0; i < state.elements.length; i++) {
|
|
720
|
+
if (state.elements[i].id === id) return state.elements[i];
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
function findComment(id) {
|
|
725
|
+
for (var i = 0; i < state.comments.length; i++) {
|
|
726
|
+
if (state.comments[i].id === id) return state.comments[i];
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
function renderBody(e) {
|
|
731
|
+
var body = el('div', { class: 'element-body' });
|
|
732
|
+
if (e.type === 'text') {
|
|
733
|
+
// Render the text as preformatted so multi-line content is preserved
|
|
734
|
+
body.appendChild(el('pre', { text: e.content || '' }));
|
|
735
|
+
} else if (e.type === 'image') {
|
|
736
|
+
if (e.content) body.appendChild(el('img', { src: e.content, alt: e.title || 'image' }));
|
|
737
|
+
else body.appendChild(el('div', { text: '(no image URL set — double-click to edit)', class: 'muted' }));
|
|
738
|
+
} else if (e.type === 'code') {
|
|
739
|
+
var pre = el('pre', null, [el('code', { class: e.language ? 'language-' + e.language : '', text: e.content || '' })]);
|
|
740
|
+
body.appendChild(pre);
|
|
741
|
+
} else if (e.type === 'diagram') {
|
|
742
|
+
var code = el('pre', { class: 'mermaid', text: e.content || '' });
|
|
743
|
+
body.appendChild(code);
|
|
744
|
+
} else if (e.type === 'ui-mockup') {
|
|
745
|
+
var comp = (e.component || '').toLowerCase();
|
|
746
|
+
if (comp === 'button') {
|
|
747
|
+
body.appendChild(el('button', { class: 'ui-mockup-button', text: e.label || 'Button' }));
|
|
748
|
+
} else if (comp === 'input') {
|
|
749
|
+
body.appendChild(el('input', {
|
|
750
|
+
class: 'ui-mockup-input', type: 'text',
|
|
751
|
+
placeholder: e.placeholder || '',
|
|
752
|
+
value: e.value || '',
|
|
753
|
+
}));
|
|
754
|
+
} else if (comp === 'card') {
|
|
755
|
+
body.appendChild(el('div', { class: 'ui-mockup-card' }, [
|
|
756
|
+
el('h4', { text: e.title || 'Card' }),
|
|
757
|
+
el('p', { text: e.body || '' }),
|
|
758
|
+
]));
|
|
759
|
+
} else {
|
|
760
|
+
body.appendChild(el('div', { text: 'Unknown ui-mockup component: ' + comp }));
|
|
761
|
+
}
|
|
762
|
+
} else {
|
|
763
|
+
body.appendChild(el('pre', { text: e.content || '' }));
|
|
764
|
+
}
|
|
765
|
+
return body;
|
|
766
|
+
}
|
|
767
|
+
function renderElement(e) {
|
|
768
|
+
var node = el('div', {
|
|
769
|
+
class: 'element' + (e.id === selectedElementId ? ' selected' : '')
|
|
770
|
+
+ (e.id === connectFromId ? ' connect-source' : ''),
|
|
771
|
+
dataset: { elementId: e.id, elementType: e.type },
|
|
772
|
+
});
|
|
773
|
+
node.style.left = e.x + 'px';
|
|
774
|
+
node.style.top = e.y + 'px';
|
|
775
|
+
node.style.width = e.width + 'px';
|
|
776
|
+
node.style.height = e.height + 'px';
|
|
777
|
+
|
|
778
|
+
var typeBadge = el('span', { class: 'type-badge', text: typeLabel(e.type) });
|
|
779
|
+
var title = el('span', { class: 'title', text: e.title || '' });
|
|
780
|
+
// Edit/Delete buttons now use data-action so event delegation in
|
|
781
|
+
// wire() can pick them up (works for htmx-injected elements too).
|
|
782
|
+
var actions = el('div', { class: 'actions' }, [
|
|
783
|
+
el('button', { type: 'button', title: 'Edit content',
|
|
784
|
+
'data-action': 'edit-element', text: '✎' }),
|
|
785
|
+
el('button', { type: 'button', title: 'Delete element',
|
|
786
|
+
'data-action': 'delete-element', text: '🗑' }),
|
|
787
|
+
]);
|
|
788
|
+
var header = el('div', { class: 'element-header' }, [typeBadge, title, actions]);
|
|
789
|
+
// Drag: pointerdown on header starts drag
|
|
790
|
+
header.addEventListener('pointerdown', function (ev) {
|
|
791
|
+
if (ev.button !== 0) return;
|
|
792
|
+
// Ignore drags that start on action buttons (they have their own handlers)
|
|
793
|
+
if (ev.target.closest('button')) return;
|
|
794
|
+
ev.stopPropagation();
|
|
795
|
+
startElementDrag(e.id, ev);
|
|
796
|
+
});
|
|
797
|
+
node.appendChild(header);
|
|
798
|
+
node.appendChild(renderBody(e));
|
|
799
|
+
|
|
800
|
+
// Click on element: select it (so connection tool knows the source)
|
|
801
|
+
node.addEventListener('click', function (ev) {
|
|
802
|
+
if (ev.target.closest('button') || ev.target.closest('input,textarea,select')) return;
|
|
803
|
+
ev.stopPropagation();
|
|
804
|
+
handleElementClick(e);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// Double-click: edit content
|
|
808
|
+
node.addEventListener('dblclick', function (ev) {
|
|
809
|
+
ev.stopPropagation();
|
|
810
|
+
openElementEditor(e);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Resize handle
|
|
814
|
+
var handle = el('div', { class: 'resize-handle', title: 'Resize' });
|
|
815
|
+
handle.addEventListener('pointerdown', function (ev) {
|
|
816
|
+
if (ev.button !== 0) return;
|
|
817
|
+
ev.stopPropagation();
|
|
818
|
+
startElementResize(e.id, ev);
|
|
819
|
+
});
|
|
820
|
+
node.appendChild(handle);
|
|
821
|
+
|
|
822
|
+
return node;
|
|
823
|
+
}
|
|
824
|
+
function typeLabel(t) {
|
|
825
|
+
if (t === 'ui-mockup') return 'UI';
|
|
826
|
+
if (t === 'text') return 'TXT';
|
|
827
|
+
if (t === 'image') return 'IMG';
|
|
828
|
+
if (t === 'code') return 'CODE';
|
|
829
|
+
if (t === 'diagram') return 'DIAG';
|
|
830
|
+
return (t || '').toUpperCase();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── Re-extract an element's state from its DOM node (used after htmx
|
|
834
|
+
// has appended server-rendered HTML so local state stays in sync). ──
|
|
835
|
+
function readElementFromNode(node) {
|
|
836
|
+
if (!node) return null;
|
|
837
|
+
return {
|
|
838
|
+
id: node.dataset.elementId,
|
|
839
|
+
type: node.dataset.elementType,
|
|
840
|
+
x: parseFloat(node.style.left) || 0,
|
|
841
|
+
y: parseFloat(node.style.top) || 0,
|
|
842
|
+
width: parseFloat(node.style.width) || 240,
|
|
843
|
+
height: parseFloat(node.style.height) || 160,
|
|
844
|
+
// Content fields are not in data-attributes (could be large); the
|
|
845
|
+
// existing in-memory copy is authoritative for content edits.
|
|
846
|
+
// Walk the in-memory state to find the matching id — but since
|
|
847
|
+
// the server just made it, it won't be there yet. Caller must
|
|
848
|
+
// merge with `props` if it has them.
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Re-draw only the connections layer (cheaper than full rerender). */
|
|
853
|
+
function rerenderConnections() { renderConnections(); }
|
|
854
|
+
|
|
855
|
+
/** Re-draw only the comment pins (cheaper than full rerender). */
|
|
856
|
+
function rerenderComments() {
|
|
857
|
+
var layer = $('#comments-layer');
|
|
858
|
+
if (!layer) return;
|
|
859
|
+
// Replace the entire layer's children with what we know about.
|
|
860
|
+
// htmx has already appended the new pin; we need to re-attach
|
|
861
|
+
// drag handlers via delegation (already wired in wire()).
|
|
862
|
+
layer.innerHTML = '';
|
|
863
|
+
for (var j = 0; j < state.comments.length; j++) {
|
|
864
|
+
var pin = renderCommentPin(state.comments[j]);
|
|
865
|
+
layer.appendChild(pin);
|
|
866
|
+
}
|
|
867
|
+
// Active highlight
|
|
868
|
+
$$('.comment-pin').forEach(function (p) {
|
|
869
|
+
p.classList.toggle('active', p.dataset.commentId === activeCommentId);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
function handleElementClick(e) {
|
|
873
|
+
if (activeTool === 'connect') {
|
|
874
|
+
if (!connectFromId) {
|
|
875
|
+
connectFromId = e.id;
|
|
876
|
+
rerender();
|
|
877
|
+
} else if (connectFromId !== e.id) {
|
|
878
|
+
// Create the connection
|
|
879
|
+
addConnection(connectFromId, e.id);
|
|
880
|
+
connectFromId = null;
|
|
881
|
+
activeTool = 'select';
|
|
882
|
+
updateToolbar();
|
|
883
|
+
rerender();
|
|
884
|
+
}
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
selectedElementId = e.id;
|
|
888
|
+
rerender();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ── Comment rendering ───────────────────────────────────────
|
|
892
|
+
function renderCommentPin(c) {
|
|
893
|
+
var pin = el('div', {
|
|
894
|
+
class: 'comment-pin' + (c.id === activeCommentId ? ' active' : ''),
|
|
895
|
+
dataset: { commentId: c.id },
|
|
896
|
+
title: (c.text || '').slice(0, 80),
|
|
897
|
+
text: commentIndex(c.id),
|
|
898
|
+
});
|
|
899
|
+
pin.style.left = c.x + 'px';
|
|
900
|
+
pin.style.top = c.y + 'px';
|
|
901
|
+
pin.addEventListener('pointerdown', function (ev) {
|
|
902
|
+
if (ev.button !== 0) return;
|
|
903
|
+
ev.stopPropagation();
|
|
904
|
+
startPinDrag(c.id, ev);
|
|
905
|
+
});
|
|
906
|
+
pin.addEventListener('click', function (ev) {
|
|
907
|
+
ev.stopPropagation();
|
|
908
|
+
openCommentPanel(c.id);
|
|
909
|
+
});
|
|
910
|
+
return pin;
|
|
911
|
+
}
|
|
912
|
+
function commentIndex(commentId) {
|
|
913
|
+
// 1-based number for visual identification (sorted by created time).
|
|
914
|
+
var sorted = state.comments.slice().sort(function (a, b) {
|
|
915
|
+
return String(a.created || '').localeCompare(String(b.created || ''));
|
|
916
|
+
});
|
|
917
|
+
for (var i = 0; i < sorted.length; i++) {
|
|
918
|
+
if (sorted[i].id === commentId) return i + 1;
|
|
919
|
+
}
|
|
920
|
+
return '?';
|
|
921
|
+
}
|
|
922
|
+
function startPinDrag(commentId, ev) {
|
|
923
|
+
var c = findComment(commentId);
|
|
924
|
+
if (!c) return;
|
|
925
|
+
var pin = ev.currentTarget;
|
|
926
|
+
pin.classList.add('dragging');
|
|
927
|
+
var pointerId = ev.pointerId;
|
|
928
|
+
pin.setPointerCapture(pointerId);
|
|
929
|
+
var startWorld = screenToWorld(ev.clientX, ev.clientY);
|
|
930
|
+
var orig = { x: c.x, y: c.y };
|
|
931
|
+
function onMove(e) {
|
|
932
|
+
var w = screenToWorld(e.clientX, e.clientY);
|
|
933
|
+
c.x = Math.round(orig.x + (w.x - startWorld.x));
|
|
934
|
+
c.y = Math.round(orig.y + (w.y - startWorld.y));
|
|
935
|
+
// Move the pin directly without a full rerender
|
|
936
|
+
var node = document.querySelector('.comment-pin[data-comment-id="' + c.id + '"]');
|
|
937
|
+
if (node) { node.style.left = c.x + 'px'; node.style.top = c.y + 'px'; }
|
|
938
|
+
}
|
|
939
|
+
function onUp() {
|
|
940
|
+
pin.releasePointerCapture(pointerId);
|
|
941
|
+
pin.classList.remove('dragging');
|
|
942
|
+
pin.removeEventListener('pointermove', onMove);
|
|
943
|
+
pin.removeEventListener('pointerup', onUp);
|
|
944
|
+
pin.removeEventListener('pointercancel', onUp);
|
|
945
|
+
scheduleSave();
|
|
946
|
+
}
|
|
947
|
+
pin.addEventListener('pointermove', onMove);
|
|
948
|
+
pin.addEventListener('pointerup', onUp);
|
|
949
|
+
pin.addEventListener('pointercancel', onUp);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ── Connection rendering (SVG) ──────────────────────────────
|
|
953
|
+
function elementCenter(e) {
|
|
954
|
+
// Center in world coordinates. The element is positioned at (e.x, e.y)
|
|
955
|
+
// with size (e.width, e.height). Center is (e.x + e.w/2, e.y + e.h/2).
|
|
956
|
+
return {
|
|
957
|
+
x: (e.x || 0) + (e.width || 0) / 2,
|
|
958
|
+
y: (e.y || 0) + (e.height || 0) / 2,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
function renderConnections() {
|
|
962
|
+
var g = $('#connections-g');
|
|
963
|
+
g.innerHTML = '';
|
|
964
|
+
for (var i = 0; i < state.connections.length; i++) {
|
|
965
|
+
var c = state.connections[i];
|
|
966
|
+
var from = findElement(c.from);
|
|
967
|
+
var to = findElement(c.to);
|
|
968
|
+
if (!from || !to) continue; // skip orphans
|
|
969
|
+
var a = elementCenter(from);
|
|
970
|
+
var b = elementCenter(to);
|
|
971
|
+
// The SVG has its own viewBox = canvas size (5000x5000) so we draw
|
|
972
|
+
// in world coordinates directly. (The transform on the parent .canvas
|
|
973
|
+
// applies uniformly to the SVG and the divs.)
|
|
974
|
+
g.appendChild(svgEl('path', {
|
|
975
|
+
class: 'connection-hit',
|
|
976
|
+
d: 'M ' + a.x + ' ' + a.y + ' L ' + b.x + ' ' + b.y,
|
|
977
|
+
}));
|
|
978
|
+
var lineClass = 'connection-line connection-arrow ' + (c.type || 'arrow');
|
|
979
|
+
g.appendChild(svgEl('path', {
|
|
980
|
+
class: lineClass,
|
|
981
|
+
d: 'M ' + a.x + ' ' + a.y + ' L ' + b.x + ' ' + b.y,
|
|
982
|
+
dataset: { connectionId: c.id },
|
|
983
|
+
}));
|
|
984
|
+
if (c.label) {
|
|
985
|
+
var mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 - 8 };
|
|
986
|
+
var text = svgEl('text', {
|
|
987
|
+
class: 'connection-label',
|
|
988
|
+
x: mid.x, y: mid.y,
|
|
989
|
+
'text-anchor': 'middle',
|
|
990
|
+
});
|
|
991
|
+
text.textContent = c.label;
|
|
992
|
+
g.appendChild(text);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ── Server interactions (via htmx.ajax — declarative data layer) ─────
|
|
998
|
+
// These functions used to call fetch() directly; now they delegate
|
|
999
|
+
// to htmx which posts the request and swaps the server's HTML
|
|
1000
|
+
// fragment into the appropriate layer. We listen for htmx:afterRequest
|
|
1001
|
+
// to capture the new entity's id (returned via HX-Trigger-After-Swap)
|
|
1002
|
+
// and update local state for the controller layer to know about.
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Add a new element. The server returns the <div class="element"> HTML
|
|
1006
|
+
* which htmx appends to #elements-layer (target + swap are on the
|
|
1007
|
+
* server's element-form). This function is called from the modal
|
|
1008
|
+
* submit flow after the form has set hidden x/y/width/height.
|
|
1009
|
+
*/
|
|
1010
|
+
function addElement(type, props) {
|
|
1011
|
+
var body = Object.assign({ type: type }, props || {});
|
|
1012
|
+
// Trigger htmx POST programmatically with the values as the form body.
|
|
1013
|
+
return new Promise(function (resolve) {
|
|
1014
|
+
// Listen for the htmx:afterRequest to capture the new id.
|
|
1015
|
+
var id = null;
|
|
1016
|
+
var oneOff = function (ev) {
|
|
1017
|
+
document.body.removeEventListener('htmx:afterRequest', oneOff);
|
|
1018
|
+
var resp = ev.detail && ev.detail.xhr && ev.detail.xhr.response;
|
|
1019
|
+
if (resp && typeof resp === 'string') {
|
|
1020
|
+
var m = resp.match(/data-element-id="([^"]+)"/);
|
|
1021
|
+
if (m) id = m[1];
|
|
1022
|
+
}
|
|
1023
|
+
if (id) {
|
|
1024
|
+
// Push the new element into local state by re-extracting from
|
|
1025
|
+
// the DOM htmx just appended. This keeps state in sync with
|
|
1026
|
+
// what the server actually wrote.
|
|
1027
|
+
var node = document.querySelector('[data-element-id="' + id + '"]');
|
|
1028
|
+
if (node) state.elements.push(readElementFromNode(node));
|
|
1029
|
+
resolve(state.elements[state.elements.length - 1]);
|
|
1030
|
+
} else {
|
|
1031
|
+
setSaveStatus('Add failed: no element id in response', 'error');
|
|
1032
|
+
resolve(null);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
document.body.addEventListener('htmx:afterRequest', oneOff);
|
|
1036
|
+
// Fire the POST through htmx.
|
|
1037
|
+
window.htmx.ajax('POST', '/api/' + encodeURIComponent(SLUG) + '/elements', {
|
|
1038
|
+
target: '#elements-layer',
|
|
1039
|
+
swap: 'beforeend',
|
|
1040
|
+
values: body,
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** Delete an element. htmx removes the DOM node; we update state after. */
|
|
1046
|
+
function deleteElement(id) {
|
|
1047
|
+
var node = document.querySelector('[data-element-id="' + id + '"]');
|
|
1048
|
+
if (node) {
|
|
1049
|
+
// Use htmx.ajax with swap='delete' so the node is removed on 200.
|
|
1050
|
+
window.htmx.ajax('DELETE',
|
|
1051
|
+
'/api/' + encodeURIComponent(SLUG) + '/elements/' + encodeURIComponent(id),
|
|
1052
|
+
{ target: node, swap: 'delete' });
|
|
1053
|
+
}
|
|
1054
|
+
// Update local state immediately (cascade is server-side).
|
|
1055
|
+
state.elements = state.elements.filter(function (e) { return e.id !== id; });
|
|
1056
|
+
state.connections = state.connections.filter(function (c) { return c.from !== id && c.to !== id; });
|
|
1057
|
+
for (var i = 0; i < state.comments.length; i++) {
|
|
1058
|
+
if (state.comments[i].elementId === id) state.comments[i].elementId = null;
|
|
1059
|
+
}
|
|
1060
|
+
if (selectedElementId === id) selectedElementId = null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Add a connection. Server returns an SVG <g data-connection-id> fragment. */
|
|
1064
|
+
function addConnection(fromId, toId, type) {
|
|
1065
|
+
var body = { from: fromId, to: toId, type: type || 'arrow' };
|
|
1066
|
+
var oneOff = function (ev) {
|
|
1067
|
+
document.body.removeEventListener('htmx:afterRequest', oneOff);
|
|
1068
|
+
var resp = ev.detail && ev.detail.xhr && ev.detail.xhr.response;
|
|
1069
|
+
if (resp && typeof resp === 'string') {
|
|
1070
|
+
var m = resp.match(/data-connection-id="([^"]+)"/);
|
|
1071
|
+
if (m) {
|
|
1072
|
+
state.connections.push({
|
|
1073
|
+
id: m[1], from: fromId, to: toId, type: type || 'arrow',
|
|
1074
|
+
});
|
|
1075
|
+
rerenderConnections(); // re-draw with real geometry
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
document.body.addEventListener('htmx:afterRequest', oneOff);
|
|
1080
|
+
window.htmx.ajax('POST', '/api/' + encodeURIComponent(SLUG) + '/connections', {
|
|
1081
|
+
target: '#connections-g',
|
|
1082
|
+
swap: 'beforeend',
|
|
1083
|
+
values: body,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Add a comment pin. Server returns the pin <div> appended to the layer. */
|
|
1088
|
+
function addComment(x, y, text, elementId) {
|
|
1089
|
+
var body = { x: x, y: y, text: text, author: AUTHOR, elementId: elementId || null };
|
|
1090
|
+
var oneOff = function (ev) {
|
|
1091
|
+
document.body.removeEventListener('htmx:afterRequest', oneOff);
|
|
1092
|
+
var resp = ev.detail && ev.detail.xhr && ev.detail.xhr.response;
|
|
1093
|
+
var newId = null;
|
|
1094
|
+
if (resp && typeof resp === 'string') {
|
|
1095
|
+
var m = resp.match(/data-comment-id="([^"]+)"/);
|
|
1096
|
+
if (m) newId = m[1];
|
|
1097
|
+
}
|
|
1098
|
+
if (newId) {
|
|
1099
|
+
state.comments.push({
|
|
1100
|
+
id: newId, x: x, y: y, text: text,
|
|
1101
|
+
author: AUTHOR, elementId: elementId || null,
|
|
1102
|
+
created: new Date().toISOString(), thread: [],
|
|
1103
|
+
});
|
|
1104
|
+
rerenderComments();
|
|
1105
|
+
openCommentPanel(newId);
|
|
1106
|
+
} else {
|
|
1107
|
+
setSaveStatus('Comment failed: no id in response', 'error');
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
document.body.addEventListener('htmx:afterRequest', oneOff);
|
|
1111
|
+
window.htmx.ajax('POST', '/api/' + encodeURIComponent(SLUG) + '/comments', {
|
|
1112
|
+
target: '#comments-layer',
|
|
1113
|
+
swap: 'beforeend',
|
|
1114
|
+
values: body,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ── Element editor (double-click to edit) ───────────────────
|
|
1119
|
+
function openElementEditor(e) {
|
|
1120
|
+
// Open the modal in edit mode. Reuse the add flow by setting hidden
|
|
1121
|
+
// fields and filling in current values.
|
|
1122
|
+
var m = $('#modal-backdrop');
|
|
1123
|
+
$('#modal-title').textContent = 'Edit element';
|
|
1124
|
+
$('#modal-ok').textContent = 'Save';
|
|
1125
|
+
$('#el-type').value = e.type;
|
|
1126
|
+
$('#el-type').disabled = true; // can't change type on edit
|
|
1127
|
+
$('#modal-type-row').hidden = false;
|
|
1128
|
+
$('#el-title').value = e.title || '';
|
|
1129
|
+
$('#el-language').value = e.language || '';
|
|
1130
|
+
$('#el-content').value = e.content || '';
|
|
1131
|
+
$('#el-component').value = e.component || 'button';
|
|
1132
|
+
$('#el-label').value = e.label || '';
|
|
1133
|
+
$('#el-placeholder').value = e.placeholder || '';
|
|
1134
|
+
$('#el-value').value = e.value || '';
|
|
1135
|
+
$('#el-card-body').value = e.body || '';
|
|
1136
|
+
syncEditorFieldsForType(e.type);
|
|
1137
|
+
m.dataset.editElementId = e.id;
|
|
1138
|
+
m.classList.add('open');
|
|
1139
|
+
m.setAttribute('aria-hidden', 'false');
|
|
1140
|
+
setTimeout(function () { $('#el-title').focus(); }, 50);
|
|
1141
|
+
}
|
|
1142
|
+
function syncEditorFieldsForType(t) {
|
|
1143
|
+
$('#el-language-row').hidden = (t !== 'code');
|
|
1144
|
+
$('#el-component-row').hidden = (t !== 'ui-mockup');
|
|
1145
|
+
$('#el-label-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'button');
|
|
1146
|
+
$('#el-placeholder-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'input');
|
|
1147
|
+
$('#el-value-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'input');
|
|
1148
|
+
$('#el-card-body-row').hidden = !(t === 'ui-mockup' && $('#el-component').value === 'card');
|
|
1149
|
+
var labelText = t === 'code' ? 'Code' : t === 'image' ? 'Image URL' : t === 'diagram' ? 'Mermaid source' : 'Content';
|
|
1150
|
+
$('#el-content-label').textContent = labelText;
|
|
1151
|
+
}
|
|
1152
|
+
function openElementAdder(type) {
|
|
1153
|
+
var m = $('#modal-backdrop');
|
|
1154
|
+
$('#modal-title').textContent = 'Add ' + typeLabel(type).toLowerCase();
|
|
1155
|
+
$('#modal-ok').textContent = 'Add';
|
|
1156
|
+
$('#el-type').value = type;
|
|
1157
|
+
$('#el-type').disabled = true;
|
|
1158
|
+
$('#modal-type-row').hidden = true;
|
|
1159
|
+
$('#el-title').value = '';
|
|
1160
|
+
$('#el-language').value = '';
|
|
1161
|
+
$('#el-content').value = '';
|
|
1162
|
+
$('#el-component').value = 'button';
|
|
1163
|
+
$('#el-label').value = '';
|
|
1164
|
+
$('#el-placeholder').value = '';
|
|
1165
|
+
$('#el-value').value = '';
|
|
1166
|
+
$('#el-card-body').value = '';
|
|
1167
|
+
syncEditorFieldsForType(type);
|
|
1168
|
+
delete m.dataset.editElementId;
|
|
1169
|
+
m.classList.add('open');
|
|
1170
|
+
m.setAttribute('aria-hidden', 'false');
|
|
1171
|
+
setTimeout(function () { $('#el-title').focus(); }, 50);
|
|
1172
|
+
}
|
|
1173
|
+
function closeModal() {
|
|
1174
|
+
var m = $('#modal-backdrop');
|
|
1175
|
+
m.classList.remove('open');
|
|
1176
|
+
m.setAttribute('aria-hidden', 'true');
|
|
1177
|
+
$('#el-type').disabled = false;
|
|
1178
|
+
}
|
|
1179
|
+
function readModalElement() {
|
|
1180
|
+
var type = $('#el-type').value;
|
|
1181
|
+
var e = {
|
|
1182
|
+
type: type,
|
|
1183
|
+
title: $('#el-title').value || '',
|
|
1184
|
+
content: $('#el-content').value || '',
|
|
1185
|
+
};
|
|
1186
|
+
if (type === 'code') e.language = $('#el-language').value || '';
|
|
1187
|
+
if (type === 'ui-mockup') {
|
|
1188
|
+
e.component = $('#el-component').value || 'button';
|
|
1189
|
+
if (e.component === 'button') e.label = $('#el-label').value || 'Button';
|
|
1190
|
+
if (e.component === 'input') {
|
|
1191
|
+
e.placeholder = $('#el-placeholder').value || '';
|
|
1192
|
+
e.value = $('#el-value').value || '';
|
|
1193
|
+
}
|
|
1194
|
+
if (e.component === 'card') {
|
|
1195
|
+
e.title = e.title || 'Card';
|
|
1196
|
+
e.body = $('#el-card-body').value || '';
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return e;
|
|
1200
|
+
}
|
|
1201
|
+
async function submitElementForm(ev) {
|
|
1202
|
+
ev.preventDefault();
|
|
1203
|
+
var m = $('#modal-backdrop');
|
|
1204
|
+
var form = $('#element-form');
|
|
1205
|
+
var data = readModalElement();
|
|
1206
|
+
var editId = m.dataset.editElementId;
|
|
1207
|
+
if (editId) {
|
|
1208
|
+
// ── EDIT MODE: PUT to /elements/<id>. The server returns the
|
|
1209
|
+
// updated <div class="element"> HTML; htmx swaps it in place of
|
|
1210
|
+
// the existing element via outerHTML. ──
|
|
1211
|
+
form.setAttribute('hx-put',
|
|
1212
|
+
'/api/' + encodeURIComponent(SLUG) + '/elements/' + encodeURIComponent(editId));
|
|
1213
|
+
form.setAttribute('hx-target', '[data-element-id="' + editId + '"]');
|
|
1214
|
+
form.setAttribute('hx-swap', 'outerHTML');
|
|
1215
|
+
// Listen for the response so we can update local state.
|
|
1216
|
+
var oneOff = function (ev2) {
|
|
1217
|
+
document.body.removeEventListener('htmx:afterRequest', oneOff);
|
|
1218
|
+
if (ev2.detail && ev2.detail.successful) {
|
|
1219
|
+
var xhr = ev2.detail.xhr;
|
|
1220
|
+
var resp = xhr && xhr.response;
|
|
1221
|
+
if (resp && typeof resp === 'string') {
|
|
1222
|
+
var m2 = resp.match(/data-element-id="([^"]+)"/);
|
|
1223
|
+
if (m2) {
|
|
1224
|
+
var id2 = m2[1];
|
|
1225
|
+
for (var i = 0; i < state.elements.length; i++) {
|
|
1226
|
+
if (state.elements[i].id === id2) {
|
|
1227
|
+
// Preserve x/y/width/height (the server keeps them,
|
|
1228
|
+
// but the PUT body may not include them — re-read from DOM).
|
|
1229
|
+
var node = document.querySelector('[data-element-id="' + id2 + '"]');
|
|
1230
|
+
if (node) {
|
|
1231
|
+
state.elements[i].x = parseFloat(node.style.left) || state.elements[i].x;
|
|
1232
|
+
state.elements[i].y = parseFloat(node.style.top) || state.elements[i].y;
|
|
1233
|
+
state.elements[i].width = parseFloat(node.style.width) || state.elements[i].width;
|
|
1234
|
+
state.elements[i].height = parseFloat(node.style.height) || state.elements[i].height;
|
|
1235
|
+
}
|
|
1236
|
+
// Update content fields from the form data
|
|
1237
|
+
for (var k in data) state.elements[i][k] = data[k];
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
rerenderConnections();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
closeModal();
|
|
1246
|
+
// Reset form attrs to default (POST mode)
|
|
1247
|
+
form.removeAttribute('hx-put');
|
|
1248
|
+
form.setAttribute('hx-target', '#elements-layer');
|
|
1249
|
+
form.setAttribute('hx-swap', 'beforeend');
|
|
1250
|
+
};
|
|
1251
|
+
document.body.addEventListener('htmx:afterRequest', oneOff);
|
|
1252
|
+
// Trigger htmx to submit the form
|
|
1253
|
+
window.htmx.trigger(form, 'submit');
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
// ── ADD MODE: center the new element in the current view ──
|
|
1257
|
+
var wrap = $('#canvas-wrap').getBoundingClientRect();
|
|
1258
|
+
var cx = (wrap.width / 2 - state.viewport.x) / state.viewport.zoom;
|
|
1259
|
+
var cy = (wrap.height / 2 - state.viewport.y) / state.viewport.zoom;
|
|
1260
|
+
if (data.type === 'code') { data.width = 360; data.height = 200; }
|
|
1261
|
+
else if (data.type === 'image') { data.width = 320; data.height = 240; }
|
|
1262
|
+
else if (data.type === 'diagram') { data.width = 360; data.height = 220; }
|
|
1263
|
+
else if (data.type === 'ui-mockup') { data.width = 280; data.height = 140; }
|
|
1264
|
+
else { data.width = 280; data.height = 160; }
|
|
1265
|
+
data.x = Math.round(cx - data.width / 2);
|
|
1266
|
+
data.y = Math.round(cy - data.height / 2);
|
|
1267
|
+
// Fill hidden inputs so the form submits the right geometry
|
|
1268
|
+
$('#el-x').value = data.x;
|
|
1269
|
+
$('#el-y').value = data.y;
|
|
1270
|
+
$('#el-width').value = data.width;
|
|
1271
|
+
$('#el-height').value = data.height;
|
|
1272
|
+
// Reset to POST (in case we previously edited)
|
|
1273
|
+
form.setAttribute('hx-target', '#elements-layer');
|
|
1274
|
+
form.setAttribute('hx-swap', 'beforeend');
|
|
1275
|
+
form.removeAttribute('hx-put');
|
|
1276
|
+
// After htmx appends, capture the new element id and update state
|
|
1277
|
+
var oneOff2 = function (ev2) {
|
|
1278
|
+
document.body.removeEventListener('htmx:afterRequest', oneOff2);
|
|
1279
|
+
if (ev2.detail && ev2.detail.successful) {
|
|
1280
|
+
var xhr = ev2.detail.xhr;
|
|
1281
|
+
var resp = xhr && xhr.response;
|
|
1282
|
+
if (resp && typeof resp === 'string') {
|
|
1283
|
+
var mm = resp.match(/data-element-id="([^"]+)"/);
|
|
1284
|
+
if (mm) {
|
|
1285
|
+
state.elements.push(Object.assign({}, data, { id: mm[1] }));
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
closeModal();
|
|
1290
|
+
};
|
|
1291
|
+
document.body.addEventListener('htmx:afterRequest', oneOff2);
|
|
1292
|
+
window.htmx.trigger(form, 'submit');
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ── Element drag/resize ─────────────────────────────────────
|
|
1296
|
+
function startElementDrag(elementId, ev) {
|
|
1297
|
+
var e = findElement(elementId);
|
|
1298
|
+
if (!e) return;
|
|
1299
|
+
if (activeTool === 'connect') return; // don't drag while connecting
|
|
1300
|
+
var pointerId = ev.pointerId;
|
|
1301
|
+
ev.currentTarget.setPointerCapture(pointerId);
|
|
1302
|
+
var startWorld = screenToWorld(ev.clientX, ev.clientY);
|
|
1303
|
+
var orig = { x: e.x, y: e.y };
|
|
1304
|
+
var node = document.querySelector('.element[data-element-id="' + e.id + '"]');
|
|
1305
|
+
function onMove(ev2) {
|
|
1306
|
+
var w = screenToWorld(ev2.clientX, ev2.clientY);
|
|
1307
|
+
e.x = Math.round(orig.x + (w.x - startWorld.x));
|
|
1308
|
+
e.y = Math.round(orig.y + (w.y - startWorld.y));
|
|
1309
|
+
if (node) { node.style.left = e.x + 'px'; node.style.top = e.y + 'px'; }
|
|
1310
|
+
renderConnections();
|
|
1311
|
+
}
|
|
1312
|
+
function onUp() {
|
|
1313
|
+
ev.currentTarget.releasePointerCapture(pointerId);
|
|
1314
|
+
ev.currentTarget.removeEventListener('pointermove', onMove);
|
|
1315
|
+
ev.currentTarget.removeEventListener('pointerup', onUp);
|
|
1316
|
+
ev.currentTarget.removeEventListener('pointercancel', onUp);
|
|
1317
|
+
scheduleSave();
|
|
1318
|
+
}
|
|
1319
|
+
ev.currentTarget.addEventListener('pointermove', onMove);
|
|
1320
|
+
ev.currentTarget.addEventListener('pointerup', onUp);
|
|
1321
|
+
ev.currentTarget.addEventListener('pointercancel', onUp);
|
|
1322
|
+
}
|
|
1323
|
+
function startElementResize(elementId, ev) {
|
|
1324
|
+
var e = findElement(elementId);
|
|
1325
|
+
if (!e) return;
|
|
1326
|
+
var pointerId = ev.pointerId;
|
|
1327
|
+
ev.currentTarget.setPointerCapture(pointerId);
|
|
1328
|
+
var startScreen = { x: ev.clientX, y: ev.clientY };
|
|
1329
|
+
var orig = { w: e.width, h: e.height };
|
|
1330
|
+
var node = document.querySelector('.element[data-element-id="' + e.id + '"]');
|
|
1331
|
+
function onMove(ev2) {
|
|
1332
|
+
var dx = (ev2.clientX - startScreen.x) / state.viewport.zoom;
|
|
1333
|
+
var dy = (ev2.clientY - startScreen.y) / state.viewport.zoom;
|
|
1334
|
+
e.width = Math.max(80, Math.round(orig.w + dx));
|
|
1335
|
+
e.height = Math.max(40, Math.round(orig.h + dy));
|
|
1336
|
+
if (node) { node.style.width = e.width + 'px'; node.style.height = e.height + 'px'; }
|
|
1337
|
+
renderConnections();
|
|
1338
|
+
}
|
|
1339
|
+
function onUp() {
|
|
1340
|
+
ev.currentTarget.releasePointerCapture(pointerId);
|
|
1341
|
+
ev.currentTarget.removeEventListener('pointermove', onMove);
|
|
1342
|
+
ev.currentTarget.removeEventListener('pointerup', onUp);
|
|
1343
|
+
ev.currentTarget.removeEventListener('pointercancel', onUp);
|
|
1344
|
+
scheduleSave();
|
|
1345
|
+
}
|
|
1346
|
+
ev.currentTarget.addEventListener('pointermove', onMove);
|
|
1347
|
+
ev.currentTarget.addEventListener('pointerup', onUp);
|
|
1348
|
+
ev.currentTarget.addEventListener('pointercancel', onUp);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// ── Pan & zoom ──────────────────────────────────────────────
|
|
1352
|
+
function startPan(ev) {
|
|
1353
|
+
if (ev.button !== 0 && ev.button !== 1) return; // left or middle
|
|
1354
|
+
if (ev.button === 0 && !spaceDown) return; // left only with space
|
|
1355
|
+
ev.preventDefault();
|
|
1356
|
+
var canvas = $('#canvas');
|
|
1357
|
+
canvas.classList.add('panning');
|
|
1358
|
+
var startX = ev.clientX, startY = ev.clientY;
|
|
1359
|
+
var orig = { x: state.viewport.x, y: state.viewport.y };
|
|
1360
|
+
function onMove(ev2) {
|
|
1361
|
+
state.viewport.x = Math.round(orig.x + (ev2.clientX - startX));
|
|
1362
|
+
state.viewport.y = Math.round(orig.y + (ev2.clientY - startY));
|
|
1363
|
+
applyViewport();
|
|
1364
|
+
}
|
|
1365
|
+
function onUp() {
|
|
1366
|
+
canvas.classList.remove('panning');
|
|
1367
|
+
document.removeEventListener('pointermove', onMove);
|
|
1368
|
+
document.removeEventListener('pointerup', onUp);
|
|
1369
|
+
document.removeEventListener('pointercancel', onUp);
|
|
1370
|
+
scheduleSave();
|
|
1371
|
+
}
|
|
1372
|
+
document.addEventListener('pointermove', onMove);
|
|
1373
|
+
document.addEventListener('pointerup', onUp);
|
|
1374
|
+
document.addEventListener('pointercancel', onUp);
|
|
1375
|
+
}
|
|
1376
|
+
function zoom(delta, ev) {
|
|
1377
|
+
var newZoom = Math.max(0.2, Math.min(3, state.viewport.zoom + delta));
|
|
1378
|
+
if (newZoom === state.viewport.zoom) return;
|
|
1379
|
+
// Zoom toward the cursor: keep the world point under the cursor fixed.
|
|
1380
|
+
if (ev) {
|
|
1381
|
+
var before = screenToWorld(ev.clientX, ev.clientY);
|
|
1382
|
+
state.viewport.zoom = newZoom;
|
|
1383
|
+
applyViewport();
|
|
1384
|
+
var after = screenToWorld(ev.clientX, ev.clientY);
|
|
1385
|
+
state.viewport.x += Math.round((after.x - before.x) * newZoom);
|
|
1386
|
+
state.viewport.y += Math.round((after.y - before.y) * newZoom);
|
|
1387
|
+
} else {
|
|
1388
|
+
state.viewport.zoom = newZoom;
|
|
1389
|
+
}
|
|
1390
|
+
applyViewport();
|
|
1391
|
+
scheduleSave();
|
|
1392
|
+
}
|
|
1393
|
+
function resetZoom() {
|
|
1394
|
+
state.viewport = { x: 0, y: 0, zoom: 1 };
|
|
1395
|
+
applyViewport();
|
|
1396
|
+
scheduleSave();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ── Comment panel ───────────────────────────────────────────
|
|
1400
|
+
function openCommentPanel(commentId) {
|
|
1401
|
+
activeCommentId = commentId;
|
|
1402
|
+
renderCommentList();
|
|
1403
|
+
var panel = $('#comment-panel');
|
|
1404
|
+
panel.classList.add('open');
|
|
1405
|
+
panel.setAttribute('aria-hidden', 'false');
|
|
1406
|
+
// Highlight the pin
|
|
1407
|
+
$$('.comment-pin').forEach(function (p) { p.classList.toggle('active', p.dataset.commentId === commentId); });
|
|
1408
|
+
setTimeout(function () { $('#new-comment').focus(); }, 200);
|
|
1409
|
+
}
|
|
1410
|
+
function closeCommentPanel() {
|
|
1411
|
+
var panel = $('#comment-panel');
|
|
1412
|
+
panel.classList.remove('open');
|
|
1413
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
1414
|
+
activeCommentId = null;
|
|
1415
|
+
$$('.comment-pin').forEach(function (p) { p.classList.remove('active'); });
|
|
1416
|
+
}
|
|
1417
|
+
function renderCommentList() {
|
|
1418
|
+
var c = findComment(activeCommentId);
|
|
1419
|
+
var list = $('#comment-list');
|
|
1420
|
+
list.innerHTML = '';
|
|
1421
|
+
if (!c) {
|
|
1422
|
+
list.appendChild(el('li', { class: 'empty', text: 'No comment selected.' }));
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
var target = c.elementId ? (findElement(c.elementId) || {}).title || 'element' : 'canvas';
|
|
1426
|
+
$('#comment-target').textContent = '· ' + target;
|
|
1427
|
+
var li = el('li', { class: 'comment' });
|
|
1428
|
+
li.appendChild(el('div', { class: 'comment-meta',
|
|
1429
|
+
text: (c.author || 'Anonymous') + ' · ' + formatDate(c.created) }));
|
|
1430
|
+
li.appendChild(el('div', { class: 'comment-text', text: c.text || '' }));
|
|
1431
|
+
list.appendChild(li);
|
|
1432
|
+
// Replies
|
|
1433
|
+
if (Array.isArray(c.thread)) {
|
|
1434
|
+
for (var i = 0; i < c.thread.length; i++) {
|
|
1435
|
+
var r = c.thread[i];
|
|
1436
|
+
var reply = el('li', { class: 'reply' });
|
|
1437
|
+
reply.appendChild(el('div', { class: 'reply-meta',
|
|
1438
|
+
text: (r.author || 'ai') + ' · ' + formatDate(r.created) }));
|
|
1439
|
+
reply.appendChild(el('div', { class: 'reply-text', text: r.text || '' }));
|
|
1440
|
+
list.appendChild(reply);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
function formatDate(iso) {
|
|
1445
|
+
if (!iso) return '';
|
|
1446
|
+
var d = new Date(iso);
|
|
1447
|
+
if (isNaN(d.getTime())) return String(iso);
|
|
1448
|
+
return d.toLocaleString();
|
|
1449
|
+
}
|
|
1450
|
+
async function submitCommentForm(ev) {
|
|
1451
|
+
ev.preventDefault();
|
|
1452
|
+
var text = $('#new-comment').value.trim();
|
|
1453
|
+
if (!text) return;
|
|
1454
|
+
if (!activeCommentId) return; // No active comment → no-op
|
|
1455
|
+
// htmx: the form already has hx-target="#comment-list" + hx-swap="outerHTML".
|
|
1456
|
+
// We just need to set hx-put to the active comment's URL.
|
|
1457
|
+
var form = $('#comment-form');
|
|
1458
|
+
form.setAttribute('hx-put',
|
|
1459
|
+
'/api/' + encodeURIComponent(SLUG) + '/comments/' + encodeURIComponent(activeCommentId));
|
|
1460
|
+
$('#reply-author').value = AUTHOR;
|
|
1461
|
+
// Listen for the response so we can update local state with the
|
|
1462
|
+
// server's updated comment (which includes the new reply in its thread).
|
|
1463
|
+
var oneOff = function (ev2) {
|
|
1464
|
+
document.body.removeEventListener('htmx:afterRequest', oneOff);
|
|
1465
|
+
if (ev2.detail && ev2.detail.successful) {
|
|
1466
|
+
var xhr = ev2.detail.xhr;
|
|
1467
|
+
var resp = xhr && xhr.response;
|
|
1468
|
+
if (resp && typeof resp === 'string') {
|
|
1469
|
+
var m = resp.match(/id="comment-([^"]+)"/);
|
|
1470
|
+
if (m) {
|
|
1471
|
+
var id = m[1];
|
|
1472
|
+
var idx = state.comments.findIndex(function (c) { return c.id === id; });
|
|
1473
|
+
if (idx >= 0) {
|
|
1474
|
+
// Update thread from the new state (server is authoritative)
|
|
1475
|
+
var threadMatch = resp.match(/<li class="reply"[\s\S]*?<\/li>/g);
|
|
1476
|
+
if (threadMatch) {
|
|
1477
|
+
state.comments[idx].thread = state.comments[idx].thread || [];
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
} else {
|
|
1483
|
+
setSaveStatus('Reply failed', 'error');
|
|
1484
|
+
}
|
|
1485
|
+
$('#new-comment').value = '';
|
|
1486
|
+
};
|
|
1487
|
+
document.body.addEventListener('htmx:afterRequest', oneOff);
|
|
1488
|
+
window.htmx.trigger(form, 'submit');
|
|
1489
|
+
// (Top-level "add new comment" happens on canvas click in comment tool.)
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// ── Render everything ───────────────────────────────────────
|
|
1493
|
+
function rerender() {
|
|
1494
|
+
// Elements
|
|
1495
|
+
var layer = $('#elements-layer');
|
|
1496
|
+
layer.innerHTML = '';
|
|
1497
|
+
for (var i = 0; i < state.elements.length; i++) {
|
|
1498
|
+
layer.appendChild(renderElement(state.elements[i]));
|
|
1499
|
+
}
|
|
1500
|
+
// Comments
|
|
1501
|
+
var commentsLayer = $('#comments-layer');
|
|
1502
|
+
commentsLayer.innerHTML = '';
|
|
1503
|
+
for (var j = 0; j < state.comments.length; j++) {
|
|
1504
|
+
commentsLayer.appendChild(renderCommentPin(state.comments[j]));
|
|
1505
|
+
}
|
|
1506
|
+
// Empty-canvas message
|
|
1507
|
+
$('#empty-canvas-msg').classList.toggle('hidden',
|
|
1508
|
+
state.elements.length > 0 || state.comments.length > 0);
|
|
1509
|
+
// Connections
|
|
1510
|
+
renderConnections();
|
|
1511
|
+
// Active comment highlight
|
|
1512
|
+
$$('.comment-pin').forEach(function (p) {
|
|
1513
|
+
p.classList.toggle('active', p.dataset.commentId === activeCommentId);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
function updateToolbar() {
|
|
1517
|
+
$$('.toolbar button[data-tool]').forEach(function (b) {
|
|
1518
|
+
b.classList.toggle('active', b.dataset.tool === activeTool);
|
|
1519
|
+
});
|
|
1520
|
+
$('#canvas').classList.toggle('connecting', activeTool === 'connect' || activeTool === 'comment');
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// ── Wire up events ──────────────────────────────────────────
|
|
1524
|
+
function wire() {
|
|
1525
|
+
// Toolbar tool selection
|
|
1526
|
+
$$('.toolbar button[data-tool]').forEach(function (b) {
|
|
1527
|
+
b.addEventListener('click', function () {
|
|
1528
|
+
activeTool = b.dataset.tool;
|
|
1529
|
+
if (activeTool !== 'connect') connectFromId = null;
|
|
1530
|
+
updateToolbar();
|
|
1531
|
+
rerender();
|
|
1532
|
+
if (['text', 'image', 'code', 'diagram', 'ui-mockup'].indexOf(activeTool) >= 0) {
|
|
1533
|
+
openElementAdder(activeTool);
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
// Zoom
|
|
1538
|
+
$('#zoom-in').addEventListener('click', function (ev) { zoom(0.1, ev); });
|
|
1539
|
+
$('#zoom-out').addEventListener('click', function (ev) { zoom(-0.1, ev); });
|
|
1540
|
+
$('#zoom-reset').addEventListener('click', resetZoom);
|
|
1541
|
+
|
|
1542
|
+
// Header — Markdown export. This stays as fetch() because it's a
|
|
1543
|
+
// download flow (Blob → URL.createObjectURL) which doesn't fit
|
|
1544
|
+
// htmx's swap model. It's not CRUD on canvas data.
|
|
1545
|
+
$('#export-md-btn').addEventListener('click', async function () {
|
|
1546
|
+
try {
|
|
1547
|
+
var res = await fetch('/api/' + encodeURIComponent(SLUG) + '/markdown-export');
|
|
1548
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1549
|
+
var md = await res.text();
|
|
1550
|
+
var blob = new Blob([md], { type: 'text/markdown' });
|
|
1551
|
+
var url = URL.createObjectURL(blob);
|
|
1552
|
+
var a = document.createElement('a');
|
|
1553
|
+
a.href = url; a.download = SLUG + '.md';
|
|
1554
|
+
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
1555
|
+
URL.revokeObjectURL(url);
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
setSaveStatus('Export failed: ' + err.message, 'error');
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Canvas pan: hold space + drag, or middle-mouse
|
|
1562
|
+
var wrap = $('#canvas-wrap');
|
|
1563
|
+
wrap.addEventListener('pointerdown', startPan);
|
|
1564
|
+
|
|
1565
|
+
// Canvas click: drop a comment pin if the comment tool is active
|
|
1566
|
+
wrap.addEventListener('click', function (ev) {
|
|
1567
|
+
// Only respond to clicks on the canvas background, not on elements
|
|
1568
|
+
if (ev.target.closest('.element') || ev.target.closest('.comment-pin')) return;
|
|
1569
|
+
if (activeTool !== 'comment') return;
|
|
1570
|
+
var w = screenToWorld(ev.clientX, ev.clientY);
|
|
1571
|
+
var text = prompt('Comment text:');
|
|
1572
|
+
if (text && text.trim()) {
|
|
1573
|
+
addComment(Math.round(w.x), Math.round(w.y), text.trim(), null);
|
|
1574
|
+
}
|
|
1575
|
+
activeTool = 'select';
|
|
1576
|
+
updateToolbar();
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
// Mouse wheel: zoom with Ctrl, otherwise no-op (the canvas is infinite)
|
|
1580
|
+
wrap.addEventListener('wheel', function (ev) {
|
|
1581
|
+
if (!ev.ctrlKey && !ev.metaKey) return;
|
|
1582
|
+
ev.preventDefault();
|
|
1583
|
+
var delta = ev.deltaY < 0 ? 0.1 : -0.1;
|
|
1584
|
+
zoom(delta, ev);
|
|
1585
|
+
}, { passive: false });
|
|
1586
|
+
|
|
1587
|
+
// Keyboard shortcuts
|
|
1588
|
+
document.addEventListener('keydown', function (ev) {
|
|
1589
|
+
// Spacebar → enter pan mode
|
|
1590
|
+
if (ev.code === 'Space' && !ev.target.matches('input,textarea,select')) {
|
|
1591
|
+
ev.preventDefault();
|
|
1592
|
+
spaceDown = true;
|
|
1593
|
+
$('#canvas').classList.add('space-down');
|
|
1594
|
+
}
|
|
1595
|
+
// Tool shortcuts
|
|
1596
|
+
if (ev.target.matches('input,textarea,select')) return;
|
|
1597
|
+
var k = ev.key.toLowerCase();
|
|
1598
|
+
if (k === 't') { activeTool = 'text'; openElementAdder('text'); updateToolbar(); }
|
|
1599
|
+
else if (k === 'i') { activeTool = 'image'; openElementAdder('image'); updateToolbar(); }
|
|
1600
|
+
else if (k === 'c') { activeTool = 'code'; openElementAdder('code'); updateToolbar(); }
|
|
1601
|
+
else if (k === 'd') { activeTool = 'diagram'; openElementAdder('diagram'); updateToolbar(); }
|
|
1602
|
+
else if (k === 'u') { activeTool = 'ui-mockup'; openElementAdder('ui-mockup'); updateToolbar(); }
|
|
1603
|
+
else if (k === 'l') { activeTool = 'connect'; connectFromId = null; updateToolbar(); }
|
|
1604
|
+
else if (k === 'm') { activeTool = 'comment'; updateToolbar(); }
|
|
1605
|
+
else if (k === 'escape') {
|
|
1606
|
+
activeTool = 'select';
|
|
1607
|
+
connectFromId = null;
|
|
1608
|
+
selectedElementId = null;
|
|
1609
|
+
closeModal();
|
|
1610
|
+
updateToolbar(); rerender();
|
|
1611
|
+
} else if (k === '+' || k === '=') { zoom(0.1, null); }
|
|
1612
|
+
else if (k === '-' || k === '_') { zoom(-0.1, null); }
|
|
1613
|
+
else if (k === '0') { resetZoom(); }
|
|
1614
|
+
// Cmd/Ctrl+Enter in comment form → submit
|
|
1615
|
+
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter' &&
|
|
1616
|
+
ev.target && ev.target.id === 'new-comment') {
|
|
1617
|
+
ev.preventDefault();
|
|
1618
|
+
$('#comment-form').requestSubmit();
|
|
1619
|
+
}
|
|
1620
|
+
});
|
|
1621
|
+
document.addEventListener('keyup', function (ev) {
|
|
1622
|
+
if (ev.code === 'Space') {
|
|
1623
|
+
spaceDown = false;
|
|
1624
|
+
$('#canvas').classList.remove('space-down');
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
// Comment form
|
|
1629
|
+
$('#comment-form').addEventListener('submit', submitCommentForm);
|
|
1630
|
+
$('#close-comment-panel').addEventListener('click', closeCommentPanel);
|
|
1631
|
+
|
|
1632
|
+
// Modal
|
|
1633
|
+
$('#modal-cancel').addEventListener('click', closeModal);
|
|
1634
|
+
$('#element-form').addEventListener('submit', submitElementForm);
|
|
1635
|
+
$('#el-component').addEventListener('change', function () {
|
|
1636
|
+
syncEditorFieldsForType('ui-mockup');
|
|
1637
|
+
});
|
|
1638
|
+
$('#el-type').addEventListener('change', function () {
|
|
1639
|
+
syncEditorFieldsForType($('#el-type').value);
|
|
1640
|
+
});
|
|
1641
|
+
$('#modal-backdrop').addEventListener('click', function (ev) {
|
|
1642
|
+
if (ev.target.id === 'modal-backdrop') closeModal();
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// Click on canvas background (no tool) → deselect
|
|
1646
|
+
$('#canvas').addEventListener('click', function (ev) {
|
|
1647
|
+
if (ev.target.id === 'canvas' || ev.target.classList.contains('empty-canvas')) {
|
|
1648
|
+
selectedElementId = null;
|
|
1649
|
+
connectFromId = null;
|
|
1650
|
+
rerender();
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// ── Event delegation for htmx-injected elements ───────────────────
|
|
1655
|
+
// Because htmx appends server-rendered HTML on add/delete, those
|
|
1656
|
+
// new nodes don't have JS-bound event listeners. We delegate clicks
|
|
1657
|
+
// on action buttons (data-action="...") to the controller layer.
|
|
1658
|
+
$('#elements-layer').addEventListener('click', function (ev) {
|
|
1659
|
+
var btn = ev.target.closest('button[data-action]');
|
|
1660
|
+
if (!btn) return;
|
|
1661
|
+
ev.stopPropagation();
|
|
1662
|
+
var elementNode = btn.closest('[data-element-id]');
|
|
1663
|
+
if (!elementNode) return;
|
|
1664
|
+
var id = elementNode.dataset.elementId;
|
|
1665
|
+
var e = findElement(id);
|
|
1666
|
+
if (!e) return;
|
|
1667
|
+
if (btn.dataset.action === 'edit-element') {
|
|
1668
|
+
openElementEditor(e);
|
|
1669
|
+
} else if (btn.dataset.action === 'delete-element') {
|
|
1670
|
+
if (confirm('Delete this element?')) deleteElement(id);
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
// ── htmx error handling ──────────────────────────────────────────
|
|
1675
|
+
document.body.addEventListener('htmx:responseError', function (ev) {
|
|
1676
|
+
var status = ev.detail && ev.detail.xhr && ev.detail.xhr.status;
|
|
1677
|
+
setSaveStatus('Request failed: HTTP ' + status, 'error');
|
|
1678
|
+
});
|
|
1679
|
+
document.body.addEventListener('htmx:sendError', function (ev) {
|
|
1680
|
+
setSaveStatus('Network error', 'error');
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
// ── htmx after-process-node: re-apply connection lines after a
|
|
1684
|
+
// server-rendered element is swapped in via outerHTML. ──
|
|
1685
|
+
document.body.addEventListener('htmx:afterSwap', function (ev) {
|
|
1686
|
+
// The target was an existing element → re-attach connections
|
|
1687
|
+
var tgt = ev.detail && ev.detail.target;
|
|
1688
|
+
if (tgt && tgt.classList && tgt.classList.contains('element')) {
|
|
1689
|
+
rerenderConnections();
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// ── Init ────────────────────────────────────────────────────
|
|
1695
|
+
function init() {
|
|
1696
|
+
document.title = TITLE + ' — Bizar Plan';
|
|
1697
|
+
$('#header-title').textContent = TITLE;
|
|
1698
|
+
applyViewport();
|
|
1699
|
+
rerender();
|
|
1700
|
+
updateToolbar();
|
|
1701
|
+
wire();
|
|
1702
|
+
}
|
|
1703
|
+
if (document.readyState === 'loading') {
|
|
1704
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
1705
|
+
} else {
|
|
1706
|
+
init();
|
|
1707
|
+
}
|
|
1708
|
+
})();
|
|
1709
|
+
</script>
|
|
1710
|
+
</body>
|
|
1711
|
+
</html>
|